Skip to content

feat(widget): add support for navigation tabs (internal)#775

Open
effie-ms wants to merge 12 commits into
mainfrom
feat/header-tabs-store
Open

feat(widget): add support for navigation tabs (internal)#775
effie-ms wants to merge 12 commits into
mainfrom
feat/header-tabs-store

Conversation

@effie-ms

@effie-ms effie-ms commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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 _navigationTabs option (not part of the public API). Each tab key maps to a variant / mode / modeOptions, and the active tab drives the displayed flow — those values are derived from the active tab, never duplicated into a store.

Implementation:

  • NavigationTabsStore replaces the old SplitModeStore and holds only tabs + activeTab. Per-tab variant/mode/modeOptions live in a static lookup and are resolved by util (getTabMode / getTabVariant / getTabModeOptions / getTabSplitMode); each field falls back to config when a tab leaves it unset.
  • NavigationTabsStoreProvider re-provides an overridden WidgetContext below the store, so every existing useWidgetConfig() consumer (container width, mode, modeOptions, …) reflects the active tab with no consumer changes.
  • HeaderTabs is a shared, key-based presentational tabs bar; HeaderNavigationTabs wires it to the store and useNavigationTabLabel resolves a tab key to its i18n label.
  • The split Swap / Bridge tabs are served by this same unified store (no separate split-mode store), preserving existing mode: 'split' behaviour. useSplitMode is derived from the active tab.
  • utils/variant.ts was renamed to utils/mode.ts.

Tab presets: default → Swap & Bridge (wide), private (compact, swap split), refuel (wide), swap-advanced / bridge-advanced (wide split), limit (compact), plus the implicit swap / bridge split tabs.

Note

The _navigationTabs option, the limit mode 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

  • I have performed a self-review and testing of my code.
  • This pull request is focused and addresses a single problem.
  • If this PR modifies the Widget API or adds new features that require documentation, I have updated the documentation in the public-docs repository.

@changeset-bot

changeset-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 11dccad

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@lifi/widget Minor
nft-checkout Patch
tanstack-router-example Patch

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

@effie-ms effie-ms self-assigned this Jun 11, 2026
@effie-ms effie-ms marked this pull request as draft June 11, 2026 07:25
@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown

E2E Examples — all passed

All examples passed in the latest run.

@effie-ms effie-ms temporarily deployed to widget-test-pr-775 June 12, 2026 13:14 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

E2E Playground results

passed  158 passed

Details

stats  158 tests across 10 suites
duration  4 minutes, 60 seconds
commit  11dccad

📥 Download full HTML report (open the run → Artifacts → playwright-report)

@effie-ms effie-ms changed the title feat: replace SplitModeStore with unified HeaderTabsStore and add jumper modes feat: add simple and advance modes switching Jun 15, 2026
@effie-ms effie-ms changed the title feat: add simple and advance modes switching feat (widget): add simple and advance modes switching Jun 15, 2026
@effie-ms effie-ms changed the title feat (widget): add simple and advance modes switching feat(widget): add simple and advance modes switching Jun 15, 2026
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

✅ E2E Dev Smoke — passing

Check Result
Dev server start (pnpm dev) ✅ started
Smoke tests ✅ passed

4 passed · 0 failed · 0 skipped · 13s

View run

Comment thread packages/widget/src/components/AppContainer.tsx Outdated
Comment thread packages/widget/src/AppLayout.tsx Outdated
@effie-ms effie-ms changed the title feat(widget): add simple and advance modes switching feat(widget): add simple and advanced tiers switch Jun 17, 2026
@effie-ms effie-ms changed the title feat(widget): add simple and advanced tiers switch feat(widget): add support for navigation tabs (internal) Jun 18, 2026
],
variant: 'wide',
// mode: 'split',
// _navigationTabs: ['default', 'private', 'refuel'], // ['swap-advanced', 'bridge-advanced', 'limit']

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For testing: Uncomment to get a view with tabs

@effie-ms effie-ms marked this pull request as ready for review June 19, 2026 11:13
@effie-ms effie-ms added the Agent Review Request triggers QA Agent Zeus label Jun 19, 2026
@github-actions github-actions Bot added QA AI Reviewing and removed Agent Review Request triggers QA Agent Zeus labels Jun 19, 2026
@effie-ms effie-ms requested a review from chybisov June 19, 2026 11:22
@lifi-qa-agent

lifi-qa-agent Bot commented Jun 19, 2026

Copy link
Copy Markdown

🔍 QA Review — EMB-451

🔗 Linear Ticket · Pull Request #775

🧠 What this ticket does

This PR replaces the old SplitModeStore (which only handled Swap/Bridge tabs in mode: 'split') with a unified NavigationTabsStore that can serve any set of header tabs driven by a new internal config option _navigationTabs. Each tab key maps to a static preset of variant, mode, and modeOptions; switching tabs re-provides the WidgetContext with those overrides so all existing useWidgetConfig() consumers see the active tab's settings transparently. The PR also adds a generic presentational HeaderTabs component, key-to-label resolution via useNavigationTabLabel, and CSS tweaks to NavigationTab to prevent tabs from wrapping. Existing mode: 'split' behaviour is preserved by feeding Swap/Bridge into the same unified store.

Verdict: Needs Work — the new store and utilities have no unit tests at all, and the label resolver has a runtime undefined risk for future key additions.


📋 Ticket Summary

Add 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 NavigationTabsStore, tab presets, label resolution, HeaderNavigationTabs, and AppContainer width support via per-tab variant switching.

Acceptance Criteria:

  1. There is a way to configure the widget to show the vertical rail "Simple"/"Advanced" (and hide it for other integrators) — ✅ Met

    • Evidence: _navigationTabs?: NavigationTabKey[] in packages/widget/src/types/widget.ts:378. When absent or empty, no tabs render (NavigationHeader.tsx guards on navigationTabsCount > 0).
  2. Each rail mode renders header tabs with corresponding labels (e.g. Swap & Bridge / Private / Limit / Gas) — ✅ Met

    • Evidence: useNavigationTabLabel.ts resolves keys to i18n strings; en.json adds header.limit, header.private, header.swapAndBridge; HeaderNavigationTabs.tsx renders the label-mapped tab bar.
  3. Switching tabs updates the active mode, and the active tab persists per the store's expected lifetime (across re-renders / page navigation within the widget) — ✅ Met

    • Evidence: setActiveTab in useNavigationTabsStore.tsx updates Zustand state; storeRef is preserved across re-renders unless the config-driven signature changes; the store is not persisted to localStorage, which matches "store's expected lifetime" (session only).
  4. The widget container fits all the tabs (renders wider) — ⚠️ Partial

    • Evidence: Tabs are prevented from wrapping via flex: 'none', whiteSpace: 'nowrap', minWidth: 'auto' in NavigationTabs.tsx. Per-tab variant: 'wide' switches the expansion panel layout. However, the author left an open TODO: what to show on smaller screens? (inline comment in AppLayout.tsx) — overflow on narrow viewports is unresolved. NavigationTabs uses overflow: 'visible' with no scroll fallback for the tab scroller.
  5. Existing (old) modes/tabs behave identically to before — no regression — ✅ Met

    • Evidence: getNavigationTabKeys falls through to splitTabKeys for mode: 'split' without _navigationTabs; getInitialActiveTab feeds getSplitMode(config.modeOptions?.split) as before; useSplitMode() replaces useSplitModeStore at all call sites; E2E playground tests pass (158 tests, CI green).

🏷️ PR Naming — ❌ Fail

feat(widget): add support for navigation tabs (internal)

The project's expected format is [TICKET-XXX] type: Description (e.g. [EMB-451] feat: add support for navigation tabs). The PR uses Conventional Commits format without the ticket prefix. The ticket ID is referenced in the PR body, so discoverability is maintained, but the title format does not match the standard.

🔎 Ticket Discoverability — ✅ Pass

EMB-451 found in: PR description body (first line: https://linear.app/lifi-linear/issue/EMB-451/...)


✅ Ticket Coverage — Medium

The 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 limit mode is wired in the type system and presets but has no corresponding page/handler in the widget (by design for this PR, but creates a configuration surface with undefined runtime behaviour).


⚠️ Issues Found (6)

# 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: _navigationTabs set vs unset, mode: 'split' with a string modeOptions vs object vs undefined, empty _navigationTabs array.
  • getInitialActiveTab: _navigationTabs with entries, mode: 'split' with getSplitMode delegation, 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 when setActiveTab is called (tab click).
  • useNavigationTabsStore: selecting tabs, activeTab, setActiveTab; verifying re-render when active tab changes.
  • useSplitMode: returns correct SplitMode when active tab is swap/bridge/swap-advanced/bridge-advanced; returns undefined for non-split tabs (e.g. default, refuel).
  • tabConfig override: useWidgetConfig() consumers inside the provider read the tab-overridden mode/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 expected t() key.
  • swap and swap-advanced map to the same label; bridge and bridge-advanced map 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, tabConfig override
  • [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 _navigationTabs config 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

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (_navigationTabs set/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 setActiveTab call
  • Missing: useSplitMode returns correct value for split tabs (swap/bridge/swap-advanced/bridge-advanced) and undefined for non-split tabs
  • Missing: tabConfig override — useWidgetConfig() consumers inside the provider read the tab-overridden mode/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 — swap and swap-advanced resolve to the same label; bridge and bridge-advanced resolve 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.tsInternalNavigationTabKey, 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant