diff --git a/.changeset/per-tab-navigation-config.md b/.changeset/per-tab-navigation-config.md new file mode 100644 index 000000000..c0b19b4a7 --- /dev/null +++ b/.changeset/per-tab-navigation-config.md @@ -0,0 +1,5 @@ +--- +'@lifi/widget': minor +--- + +Internal: `_navigationTabs` entries now carry a per-tab `config` (`Partial`) instead of just a key, so an active tab can override any widget config field. Removes the built-in per-key preset table. `_navigationTabs` remains internal and is not part of the public API. diff --git a/packages/widget-playground/src/defaultWidgetConfig.ts b/packages/widget-playground/src/defaultWidgetConfig.ts index d24b0af9a..bf052a8a1 100644 --- a/packages/widget-playground/src/defaultWidgetConfig.ts +++ b/packages/widget-playground/src/defaultWidgetConfig.ts @@ -59,7 +59,16 @@ export const widgetBaseConfig: WidgetConfig = { ], variant: 'wide', // mode: 'split', - // _navigationTabs: ['default', 'private', 'refuel'], // ['swap-advanced', 'bridge-advanced', 'limit'] + // _navigationTabs: [ + // { tabKey: 'default', config: { variant: 'wide', mode: 'default' } }, + // { tabKey: 'private', config: { variant: 'compact', mode: 'default', requiredUI: { toAddress: true } } }, + // { tabKey: 'refuel', config: { variant: 'wide', mode: 'refuel' } }, + // ], + // _navigationTabs: [ + // { tabKey: 'swap-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'swap' } } }, + // { tabKey: 'bridge-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'bridge' } } }, + // { tabKey: 'limit', config: { variant: 'compact', mode: 'limit' } }, + // ], // hiddenUI: { // chainSidebar: true, // }, diff --git a/packages/widget/src/stores/navigationTabs/types.ts b/packages/widget/src/stores/navigationTabs/types.ts index 29853a2e2..c601e00ba 100644 --- a/packages/widget/src/stores/navigationTabs/types.ts +++ b/packages/widget/src/stores/navigationTabs/types.ts @@ -1,20 +1,5 @@ import type { StoreApi, UseBoundStore } from 'zustand' -import type { - ModeOptions, - NavigationTabKey, - WidgetMode, - WidgetVariant, -} from '../../types/widget.js' - -export interface NavigationTab { - /** Stable, language-independent identity; resolved to a label via a hook. */ - key: NavigationTabKey - /** When omitted, the tab inherits `config.variant`. */ - variant?: WidgetVariant - /** When omitted, the tab inherits `config.mode`. */ - mode?: WidgetMode - modeOptions?: ModeOptions -} +import type { NavigationTabKey } from '../../types/widget.js' export interface NavigationTabsState { /** Tabs to render in the header, resolved from config (empty when none). */ diff --git a/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx b/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx index e65eed124..c2249f6fd 100644 --- a/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx +++ b/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx @@ -20,11 +20,8 @@ import type { import type { NavigationTabsState, NavigationTabsStore } from './types.js' import { getInitialActiveTab, - getNavigationTabKeys, - getTabMode, - getTabModeOptions, - getTabSplitMode, - getTabVariant, + getNavigationTabs, + resolveSplitMode, } from './utils.js' const NavigationTabsStoreContext = createContext( @@ -35,7 +32,8 @@ export function NavigationTabsStoreProvider({ children, config, }: PropsWithChildren<{ config: WidgetConfig }>): JSX.Element { - const tabs = getNavigationTabKeys(config) + const navigationTabs = getNavigationTabs(config) + const tabs = navigationTabs.map((tab) => tab.tabKey) const initialActiveTab = getInitialActiveTab(config) // Recreate (re-seeding tabs + active tab) only when the config-driven inputs // change — not on runtime tab clicks, which must be preserved. @@ -49,22 +47,20 @@ export function NavigationTabsStoreProvider({ signatureRef.current = signature } - // Override the widget config with the active tab's mode, variant and - // modeOptions (each falling back to the config), so the rest of the widget - // reads tab-driven config transparently. No-op when there is no active tab. + // Layer the active tab's config overrides on top of the widget config, so the + // rest of the widget reads tab-driven config transparently. Omitted fields + // fall back to the widget config. No-op when there is no active tab. const widgetConfig = useWidgetConfig() const activeTab = store((state) => state.activeTab) const tabConfig = useMemo(() => { - if (!activeTab) { + const activeConfig = navigationTabs.find( + (tab) => tab.tabKey === activeTab + )?.config + if (!activeConfig) { return widgetConfig } - return { - ...widgetConfig, - mode: getTabMode(widgetConfig, activeTab), - variant: getTabVariant(widgetConfig, activeTab), - modeOptions: getTabModeOptions(widgetConfig, activeTab), - } - }, [widgetConfig, activeTab]) + return { ...widgetConfig, ...activeConfig } + }, [widgetConfig, activeTab, navigationTabs]) return ( @@ -90,9 +86,11 @@ export function useNavigationTabsStore( return useStore(useShallow(selector)) } -/** Effective split mode derived from the active tab. */ +/** Effective split mode derived from the active tab's resolved config. */ export function useSplitMode(): SplitMode | undefined { - return useNavigationTabsStore((state) => getTabSplitMode(state.activeTab)) + const activeTab = useNavigationTabsStore((state) => state.activeTab) + const config = useWidgetConfig() + return resolveSplitMode(config, activeTab) } const createNavigationTabsStore = ( diff --git a/packages/widget/src/stores/navigationTabs/utils.test.ts b/packages/widget/src/stores/navigationTabs/utils.test.ts index e2d1fae00..bf555428e 100644 --- a/packages/widget/src/stores/navigationTabs/utils.test.ts +++ b/packages/widget/src/stores/navigationTabs/utils.test.ts @@ -1,28 +1,28 @@ import { describe, expect, it } from 'vitest' -import type { WidgetConfig } from '../../types/widget.js' +import type { NavigationTabConfig, WidgetConfig } from '../../types/widget.js' import { getInitialActiveTab, - getNavigationTabKeys, - getTabMode, - getTabModeOptions, - getTabSplitMode, - getTabVariant, - splitTabKeys, + getNavigationTabs, + resolveSplitMode, } from './utils.js' const config = (overrides: Partial = {}): WidgetConfig => overrides as WidgetConfig -describe('getNavigationTabKeys', () => { +const tab = ( + tabKey: NavigationTabConfig['tabKey'], + config: NavigationTabConfig['config'] = {} +): NavigationTabConfig => ({ tabKey, config }) + +describe('getNavigationTabs', () => { it('prefers configured tabs, else split tabs, else none', () => { + const tabs = [tab('private', { mode: 'split' })] + expect(getNavigationTabs(config({ _navigationTabs: tabs }))).toBe(tabs) expect( - getNavigationTabKeys(config({ _navigationTabs: ['private'] })) - ).toEqual(['private']) - expect(getNavigationTabKeys(config({ mode: 'split' }))).toEqual( - splitTabKeys - ) + getNavigationTabs(config({ mode: 'split' })).map((t) => t.tabKey) + ).toEqual(['swap', 'bridge']) expect( - getNavigationTabKeys( + getNavigationTabs( config({ mode: 'split', modeOptions: { split: 'swap' } }) ) ).toEqual([]) @@ -32,7 +32,9 @@ describe('getNavigationTabKeys', () => { describe('getInitialActiveTab', () => { it('seeds the first configured tab or the split selection', () => { expect( - getInitialActiveTab(config({ _navigationTabs: ['refuel', 'private'] })) + getInitialActiveTab( + config({ _navigationTabs: [tab('refuel'), tab('private')] }) + ) ).toBe('refuel') expect( getInitialActiveTab( @@ -43,23 +45,36 @@ describe('getInitialActiveTab', () => { }) }) -describe('getTabSplitMode', () => { - it('resolves split tabs to their side, undefined otherwise', () => { - expect(getTabSplitMode('swap-advanced')).toBe('swap') - expect(getTabSplitMode('bridge')).toBe('bridge') - expect(getTabSplitMode('default')).toBeUndefined() +describe('resolveSplitMode', () => { + it('derives split mode from the active tab when tabs are configured', () => { + const tabs = [ + tab('swap', { mode: 'split', modeOptions: { split: 'swap' } }), + tab('bridge', { mode: 'split', modeOptions: { split: 'bridge' } }), + ] + const widgetConfig = config({ _navigationTabs: tabs, mode: 'split' }) + expect(resolveSplitMode(widgetConfig, 'bridge')).toBe('bridge') + expect(resolveSplitMode(widgetConfig, 'swap')).toBe('swap') + }) + + it('returns undefined when the active tab is not in split mode', () => { + const tabs = [tab('refuel', { mode: 'refuel' }), tab('private')] + // Even though the widget config is split, the active (non-split) tab wins. + const widgetConfig = config({ _navigationTabs: tabs, mode: 'split' }) + expect(resolveSplitMode(widgetConfig, 'refuel')).toBeUndefined() }) -}) -describe('per-tab config derivation', () => { - it('uses the tab preset, falling back to config', () => { - expect(getTabVariant(config({ variant: 'compact' }), 'default')).toBe( - 'wide' - ) - expect(getTabVariant(config({ variant: 'compact' }), 'swap')).toBe( - 'compact' - ) - expect(getTabMode(config({ mode: 'default' }), 'refuel')).toBe('refuel') - expect(getTabModeOptions(config(), 'private')).toEqual({ split: 'swap' }) + it('resolves implicit split tabs (swap/bridge) when none are configured', () => { + const widgetConfig = config({ mode: 'split' }) + expect(resolveSplitMode(widgetConfig, 'swap')).toBe('swap') + expect(resolveSplitMode(widgetConfig, 'bridge')).toBe('bridge') + }) + + it('falls back to the widget config when there are no tabs at all', () => { + expect( + resolveSplitMode( + config({ mode: 'split', modeOptions: { split: 'swap' } }) + ) + ).toBe('swap') + expect(resolveSplitMode(config({ mode: 'default' }))).toBeUndefined() }) }) diff --git a/packages/widget/src/stores/navigationTabs/utils.ts b/packages/widget/src/stores/navigationTabs/utils.ts index 47a424cdd..960b0c4a2 100644 --- a/packages/widget/src/stores/navigationTabs/utils.ts +++ b/packages/widget/src/stores/navigationTabs/utils.ts @@ -1,72 +1,24 @@ import type { - InternalNavigationTabKey, - ModeOptions, + NavigationTabConfig, NavigationTabKey, SplitMode, - SplitNavigationTabKey, WidgetConfig, - WidgetMode, - WidgetVariant, } from '../../types/widget.js' import { getSplitMode } from '../../utils/mode.js' -import type { NavigationTab } from './types.js' - -/** Configured navigation tabs by key. Labels are resolved from the key by a hook. */ -const navigationTabsByKey: Record = { - default: { key: 'default', variant: 'wide', mode: 'default' }, - private: { - key: 'private', - variant: 'compact', - mode: 'split', - modeOptions: { split: 'swap' }, - }, - refuel: { key: 'refuel', variant: 'wide', mode: 'refuel' }, - limit: { key: 'limit', variant: 'compact', mode: 'limit' }, - 'swap-advanced': { - key: 'swap-advanced', - variant: 'wide', - mode: 'split', - modeOptions: { split: 'swap' }, - }, - 'bridge-advanced': { - key: 'bridge-advanced', - variant: 'wide', - mode: 'split', - modeOptions: { split: 'bridge' }, - }, -} /** Split-mode tabs (Swap / Bridge), shown when no navigation tabs are configured. */ -const splitTabs: NavigationTab[] = [ - { - key: 'swap', - mode: 'split', - modeOptions: { split: 'swap' }, - }, +const splitTabs: NavigationTabConfig[] = [ + { tabKey: 'swap', config: { mode: 'split', modeOptions: { split: 'swap' } } }, { - key: 'bridge', - mode: 'split', - modeOptions: { split: 'bridge' }, + tabKey: 'bridge', + config: { mode: 'split', modeOptions: { split: 'bridge' } }, }, ] -const splitTabsByKey = Object.fromEntries( - splitTabs.map((tab) => [tab.key, tab]) -) as Record - -/** Combined lookup across configured and split tabs. */ -const tabByKey: Record = { - ...navigationTabsByKey, - ...splitTabsByKey, -} - -/** Tab keys shown in split mode when no navigation tabs are configured. */ -export const splitTabKeys: NavigationTabKey[] = splitTabs.map((tab) => tab.key) - /** Resolves which navigation tabs the header should render from the config. */ -export const getNavigationTabKeys = ( +export const getNavigationTabs = ( config: WidgetConfig -): NavigationTabKey[] => { +): NavigationTabConfig[] => { if (config._navigationTabs?.length) { return config._navigationTabs } @@ -75,7 +27,7 @@ export const getNavigationTabKeys = ( config.mode === 'split' && typeof config.modeOptions?.split !== 'string' ) { - return splitTabKeys + return splitTabs } return [] } @@ -89,46 +41,32 @@ export const getInitialActiveTab = ( config: WidgetConfig ): NavigationTabKey | undefined => { if (config._navigationTabs?.length) { - return config._navigationTabs[0] + return config._navigationTabs[0].tabKey } return config.mode === 'split' ? getSplitMode(config.modeOptions?.split) : undefined } -/** Split mode (`swap`/`bridge`) implied by a tab, or `undefined` if not split. */ -export const getTabSplitMode = ( - key?: NavigationTabKey +/** + * Resolves the effective split mode. With tabs configured it derives solely from + * the active tab's own config; with no tabs at all it falls back to the widget + * config. + */ +export const resolveSplitMode = ( + config: WidgetConfig, + activeTab?: NavigationTabKey ): SplitMode | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.mode === 'split' - ? getSplitMode(tab.modeOptions?.split) + const navigationTabs = getNavigationTabs(config) + if (navigationTabs.length) { + const activeConfig = navigationTabs.find( + (tab) => tab.tabKey === activeTab + )?.config + return activeConfig?.mode === 'split' + ? getSplitMode(activeConfig.modeOptions?.split) + : undefined + } + return config.mode === 'split' + ? getSplitMode(config.modeOptions?.split) : undefined } - -/** Variant for a tab, falling back to the widget config's variant when unset. */ -export const getTabVariant = ( - config: WidgetConfig, - key?: NavigationTabKey -): WidgetVariant | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.variant ?? config.variant -} - -/** Mode for a tab, falling back to the widget config's mode when unset. */ -export const getTabMode = ( - config: WidgetConfig, - key?: NavigationTabKey -): WidgetMode | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.mode ?? config.mode -} - -/** Mode options for a tab, falling back to the widget config's when unset. */ -export const getTabModeOptions = ( - config: WidgetConfig, - key?: NavigationTabKey -): ModeOptions | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.modeOptions ?? config.modeOptions -} diff --git a/packages/widget/src/types/widget.ts b/packages/widget/src/types/widget.ts index 33e0ac311..8bff2a9b0 100644 --- a/packages/widget/src/types/widget.ts +++ b/packages/widget/src/types/widget.ts @@ -74,6 +74,21 @@ export type InternalNavigationTabKey = | 'limit' export type NavigationTabKey = InternalNavigationTabKey | SplitNavigationTabKey +/** + * A header navigation tab: a stable key (drives identity and label) paired with + * the widget config overrides applied while the tab is active. + * @internal Not part of the public API. + */ +export interface NavigationTabConfig { + tabKey: NavigationTabKey + /** + * Config overrides applied while this tab is active. Note that `sdkConfig`, + * `theme`, `appearance` and `languages` are read above the tab provider and + * are therefore not tab-overridable — setting them here has no effect. + */ + config: Partial +} + export type Appearance = PaletteMode | 'system' export interface NavigationProps { /** @@ -369,11 +384,12 @@ export interface WidgetConfig { modeOptions?: ModeOptions /** * Header navigation tabs, in order. When set, the widget renders a tab bar - * and the active tab drives the displayed flow. Each entry is a tab key. + * and the active tab applies its `config` overrides on top of the widget + * config. Each entry pairs a tab key with its config. * * @internal Not part of the public API. */ - _navigationTabs?: NavigationTabKey[] + _navigationTabs?: NavigationTabConfig[] appearance?: Appearance theme?: WidgetTheme