feat(widget): add support for navigation tabs (internal)#775
Conversation
🦋 Changeset detectedLatest commit: 11dccad The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
E2E Examples — all passedAll examples passed in the latest run. |
E2E Playground resultsDetails
📥 Download full HTML report (open the run → Artifacts → |
✅ E2E Dev Smoke — passing
4 passed · 0 failed · 0 skipped · 13s |
| ], | ||
| variant: 'wide', | ||
| // mode: 'split', | ||
| // _navigationTabs: ['default', 'private', 'refuel'], // ['swap-advanced', 'bridge-advanced', 'limit'] |
There was a problem hiding this comment.
For testing: Uncomment to get a view with tabs
🔍 QA Review — EMB-451🧠 What this ticket doesThis PR replaces the old
📋 Ticket SummaryAdd support for tabs passed from Simple and Advanced Jumper modes The ticket describes adding two vertical rail modes ("Simple" and "Advanced", 3 tabs each) to the widget by making header tabs config-driven. The PR delivers the foundational infrastructure: the Acceptance Criteria:
🏷️ PR Naming — ❌ Fail
The project's expected format is 🔎 Ticket Discoverability — ✅ Pass
✅ Ticket Coverage — MediumThe foundational infrastructure for config-driven navigation tabs is fully delivered: store, presets, label resolution, component wiring, type definitions, changeset, and i18n keys. The tab-switching flow and split-mode backwards compatibility are well-reasoned. Coverage is Medium rather than High because: (a) there are no unit tests for any of the new store logic, utilities, or components; (b) the small-screen/overflow gap in AC #4 is unresolved; and (c) the
|
| # | Severity | Type | Issue |
|---|---|---|---|
| 1 | 🟠 High | Test gap | No unit tests for NavigationTabsStore utilities (utils.ts) |
| 2 | 🟠 High | Test gap | No unit tests for useNavigationTabsStore / useSplitMode hooks |
| 3 | 🟠 High | Test gap | No unit tests for useNavigationTabLabel |
| 4 | 🟡 Medium | Code | useNavigationTabLabel switch has no default case — silent undefined at runtime |
| 5 | 🟡 Medium | AC gap | Small-screen overflow for navigation tab bar is unresolved (open TODO) |
| 6 | 🟢 Low | API surface | InternalNavigationTabKey and InternalWidgetMode are publicly exported |
🟠 [High] No unit tests for NavigationTabsStore utilities (utils.ts)
packages/widget/src/stores/navigationTabs/utils.ts introduces six exported functions — getNavigationTabKeys, getInitialActiveTab, getTabSplitMode, getTabVariant, getTabMode, getTabModeOptions — plus the splitTabKeys constant. None have a corresponding test file. These functions encode the core business logic of this feature: which tabs to show, which tab to seed as active, and how to derive mode/variant from a tab key. Critical branches that need coverage:
getNavigationTabKeys:_navigationTabsset vs unset,mode: 'split'with a string modeOptions vs object vs undefined, empty_navigationTabsarray.getInitialActiveTab:_navigationTabswith entries,mode: 'split'withgetSplitModedelegation, non-split modes.getTabSplitMode: split-mode tab, non-split tab, unknown/undefined key.getTabVariant,getTabMode,getTabModeOptions: tab with preset, tab that inherits from config.
Suggestion: Create packages/widget/src/stores/navigationTabs/utils.test.ts and cover each function with happy path + all conditional branches using Vitest.
🟠 [High] No unit tests for useNavigationTabsStore / useSplitMode hooks
packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx exports NavigationTabsStoreProvider, useNavigationTabsStore, and useSplitMode. None have unit tests. Key scenarios that need coverage:
NavigationTabsStoreProvider: store is created on first render, recreated when signature changes (config tabs change), NOT recreated whensetActiveTabis called (tab click).useNavigationTabsStore: selectingtabs,activeTab,setActiveTab; verifying re-render when active tab changes.useSplitMode: returns correctSplitModewhen active tab isswap/bridge/swap-advanced/bridge-advanced; returnsundefinedfor non-split tabs (e.g.default,refuel).tabConfigoverride:useWidgetConfig()consumers inside the provider read the tab-overriddenmode/variant/modeOptions, not the original config.
Suggestion: Create packages/widget/src/stores/navigationTabs/useNavigationTabsStore.test.tsx using React Testing Library + renderHook.
🟠 [High] No unit tests for useNavigationTabLabel
packages/widget/src/stores/navigationTabs/useNavigationTabLabel.ts resolves every NavigationTabKey to its i18n string. No test file exists. Scenarios to cover:
- Each of the six case branches (
default,private,refuel,swap/swap-advanced,bridge/bridge-advanced,limit) maps to the expectedt()key. swapandswap-advancedmap to the same label;bridgeandbridge-advancedmap to the same label (shared cases).
Suggestion: Create packages/widget/src/stores/navigationTabs/useNavigationTabLabel.test.ts mocking useTranslation.
🟡 [Medium] useNavigationTabLabel switch has no default case — silent undefined at runtime if the union is ever extended
packages/widget/src/stores/navigationTabs/useNavigationTabLabel.ts
The inner function is typed as (key: NavigationTabKey) => string, but the switch statement covers only the current members of the union with no default arm. If NavigationTabKey gains a new member in a future PR (which is likely, given this is an actively growing feature), TypeScript will not warn — the function will silently return undefined at runtime, causing the label to disappear from the tab and potentially breaking the UI.
Suggestion: Add a default exhaustiveness guard:
default: {
const _exhaustive: never = key
return _exhaustive
}Or at minimum a safe fallback: default: return key so the raw key renders rather than nothing.
🟡 [Medium] Small-screen overflow for navigation tab bar is unresolved — open TODO in AppLayout.tsx
The inline PR comment on packages/widget/src/AppLayout.tsx reads: TODO: what to show on smaller screens? The NavigationTabs scroller is set to overflow: 'visible !important', which means tabs that exceed the container width will spill outside the widget rather than scrolling. AC #4 states "The widget container fits all the tabs (renders wider)" but does not address what happens on narrow viewports where the widget cannot be rendered wider (e.g. mobile, compact embeds). The three-tab "Simple" rail at compact width could produce visual overflow.
Suggestion: Before merging, decide and implement a behaviour for narrow screens (clip with scroll, collapse to a menu, hide tabs). If it's intentionally deferred to a follow-up ticket, link the ticket in the TODO comment so it's tracked.
🟢 [Low] InternalNavigationTabKey, InternalWidgetMode, and _navigationTabs are part of the public type export
packages/widget/src/types/widget.ts / packages/widget/src/index.ts
packages/widget/src/index.ts includes export * from './types/widget.js', which re-exports InternalNavigationTabKey, InternalWidgetMode, SplitNavigationTabKey, and NavigationTabKey — all annotated @internal in JSDoc. The _navigationTabs field is also part of the exported WidgetConfig interface. At the TypeScript level, external integrators can reference and use these types without any barrier. The underscore prefix and JSDoc @internal are conventions only.
This is a known limitation of the current approach and is called out in the changeset. It is noted here as a Low item because future API stability work (e.g. a @lifi/widget/internal subpath or @alpha tagging) would address it.
Suggestion: At minimum, consider using the TypeScript /** @internal */ annotation consistently and document the stability guarantee explicitly in the _navigationTabs JSDoc. For stronger enforcement, move internal-only types to a file not re-exported from index.ts.
🧪 Test Coverage
| Layer | Score | Files reviewed |
|---|---|---|
| Unit (Vitest) | None | packages/widget/src/stores/navigationTabs/ — no test files found; existing packages/widget/src/stores/settings/createSettingsStore.test.ts reviewed as reference pattern |
| E2e (Playwright) | Partial | e2e/tests/playground/settings.mode.spec.ts, settings.mode-variant.spec.ts, settings.persistence.spec.ts — existing split-mode tests pass (158 tests green in CI); no new e2e spec added for _navigationTabs-configured tabs |
ℹ️ e2e/ folder present — Playwright coverage will apply once the suite is active. The existing playground specs cover mode: 'split' regression adequately via the Swap/Bridge tab strip tests.
Gaps — all are requested changes, every item must be addressed or explicitly accepted:
Unit gaps:
[High] getNavigationTabKeys— all branches (with/without_navigationTabs, split with string vs object vs undefined modeOptions, empty array)[High] getInitialActiveTab— with_navigationTabs, with split mode, with non-split modes[High] getTabSplitMode— split-mode tab, non-split tab, undefined key[High] getTabVariant / getTabMode / getTabModeOptions— tab with preset, tab that falls back to config[High] useNavigationTabsStore— store creation, signature-triggered recreation, tab-click preservation,tabConfigoverride[High] useSplitMode— returns correct value for split tabs, undefined for non-split tabs[High] useNavigationTabLabel— all six case branches; shared cases for swap/swap-advanced and bridge/bridge-advanced
E2e gaps:
[Medium] _navigationTabs configured tabs— no playground e2e test exercises the new_navigationTabsconfig path: rendering a 3-tab bar, switching tabs, verifying mode changes, verifying form fields are cleared on switch
🔗 Downstream Impact
Blocks: EMB-452 (Add Send/Receive amount input cards for Jumper modes)
EMB-452 will depend on the _navigationTabs infrastructure and tab-driven mode switching delivered in this PR. The key dependency is that the active tab correctly sets mode and modeOptions via tabConfig — so when EMB-452 renders amount input cards conditionally based on useWidgetConfig().mode, it will receive the correct value per active tab. This contract appears correctly implemented. The open small-screen TODO (issue #5) could affect how EMB-452 input cards lay out in narrow configurations, so that gap should be resolved before or alongside EMB-452 if compact/mobile support is in scope.
QA Agent — 2026-06-19
There was a problem hiding this comment.
Requesting changes on 6 items — each requires either a code fix or an explicit acceptance comment with justification before this review is considered complete.
| # | Severity | Type | Issue / File |
|---|---|---|---|
| 1 | 🟠 High | Test gap | packages/widget/src/stores/navigationTabs/utils.test.ts (new) |
| 2 | 🟠 High | Test gap | packages/widget/src/stores/navigationTabs/useNavigationTabsStore.test.tsx (new) |
| 3 | 🟠 High | Test gap | packages/widget/src/stores/navigationTabs/useNavigationTabLabel.test.ts (new) |
| 4 | 🟡 Medium | Code | useNavigationTabLabel switch missing default case — silent undefined at runtime |
| 5 | 🟡 Medium | AC gap | Small-screen overflow for navigation tab bar unresolved (open TODO in AppLayout.tsx) |
| 6 | 🟢 Low | API surface | InternalNavigationTabKey and InternalWidgetMode publicly exported via index.ts |
1. [High] Test gap — utils.test.ts (new)
- Missing:
getNavigationTabKeys— all branches (_navigationTabsset/unset,mode: 'split'with string/object/undefined modeOptions, empty array) - Missing:
getInitialActiveTab— with_navigationTabs, with split mode, with non-split modes - Missing:
getTabSplitMode— split-mode tab, non-split tab, undefined key - Missing:
getTabVariant/getTabMode/getTabModeOptions— tab with preset, tab that inherits from config
2. [High] Test gap — useNavigationTabsStore.test.tsx (new)
- Missing: store created on first render, recreated on signature change, NOT recreated on
setActiveTabcall - Missing:
useSplitModereturns correct value for split tabs (swap/bridge/swap-advanced/bridge-advanced) andundefinedfor non-split tabs - Missing:
tabConfigoverride —useWidgetConfig()consumers inside the provider read the tab-overriddenmode/variant/modeOptions
3. [High] Test gap — useNavigationTabLabel.test.ts (new)
- Missing: each of the six case branches maps to the expected
t()key - Missing: shared-case verification —
swapandswap-advancedresolve to the same label;bridgeandbridge-advancedresolve to the same label
4. [Medium] useNavigationTabLabel switch missing default case
packages/widget/src/stores/navigationTabs/useNavigationTabLabel.ts — the inner function is typed as (key: NavigationTabKey) => string but has no default arm. When NavigationTabKey gains a new member, TypeScript will not warn and the function will silently return undefined at runtime, removing the tab label from the UI. Add an exhaustiveness guard:
default: {
const _exhaustive: never = key
return _exhaustive
}Or at minimum: default: return key so the raw key renders rather than nothing.
5. [Medium] Small-screen overflow unresolved
packages/widget/src/AppLayout.tsx has an open TODO: what to show on smaller screens? inline comment. The tab scroller uses overflow: 'visible !important' — tabs that exceed the container width will spill outside the widget on narrow viewports. Before merging, decide on a behaviour (scroll, collapse to menu, hide tabs) or link a follow-up ticket in the TODO so this is tracked.
6. [Low] Internal types publicly exported
packages/widget/src/types/widget.ts — InternalNavigationTabKey, InternalWidgetMode, and _navigationTabs are all re-exported from index.ts via export * from './types/widget.js'. They are guarded only by JSDoc @internal and underscore convention. Consider moving internal-only types to a file not re-exported from the package root, or document the stability contract explicitly in the JSDoc.
💡 Once you've addressed the items above, re-apply the "Agent Review Request" label to trigger an automated re-review.
Which Linear task is linked to this PR?
https://linear.app/lifi-linear/issue/EMB-451/enable-simple-and-advanced-jumper-modes-on-widget
Why was it implemented this way?
For testing: uncomment this line from the code https://github.com/lifinance/widget/pull/775/changes#diff-7fb6dbe81ac16ec6a71b3f658593e2711e2a94393e9e9a57014e4546eb3c97acR63
Header tabs are now config-driven through a new internal
_navigationTabsoption (not part of the public API). Each tab key maps to avariant/mode/modeOptions, and the active tab drives the displayed flow — those values are derived from the active tab, never duplicated into a store.Implementation:
NavigationTabsStorereplaces the oldSplitModeStoreand holds onlytabs+activeTab. Per-tabvariant/mode/modeOptionslive in a static lookup and are resolved by util (getTabMode/getTabVariant/getTabModeOptions/getTabSplitMode); each field falls back toconfigwhen a tab leaves it unset.NavigationTabsStoreProviderre-provides an overriddenWidgetContextbelow the store, so every existinguseWidgetConfig()consumer (container width, mode, modeOptions, …) reflects the active tab with no consumer changes.HeaderTabsis a shared, key-based presentational tabs bar;HeaderNavigationTabswires it to the store anduseNavigationTabLabelresolves a tab key to its i18n label.mode: 'split'behaviour.useSplitModeis derived from the active tab.utils/variant.tswas renamed toutils/mode.ts.Tab presets:
default→ Swap & Bridge (wide),private(compact, swap split),refuel(wide),swap-advanced/bridge-advanced(wide split),limit(compact), plus the implicitswap/bridgesplit tabs.Note
The
_navigationTabsoption, thelimitmode and the advanced tab keys are all@internal— not part of the public widget API.Visual showcase (Screenshots or Videos)
Tabs can be enabled from config:
https://github.com/user-attachments/assets/82547e6a-7e15-4848-85db-d8afe34ab680
https://github.com/user-attachments/assets/956457ce-2d7b-46fb-95d6-6aa5ec188f90
Checklist before requesting a review