Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/per-tab-navigation-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lifi/widget': minor
---

Internal: `_navigationTabs` entries now carry a per-tab `config` (`Partial<WidgetConfig>`) 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.
11 changes: 10 additions & 1 deletion packages/widget-playground/src/defaultWidgetConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
// },
Expand Down
17 changes: 1 addition & 16 deletions packages/widget/src/stores/navigationTabs/types.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavigationTabsStore | null>(
Expand All @@ -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.
Expand All @@ -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 (
<NavigationTabsStoreContext value={store}>
Expand All @@ -90,9 +86,11 @@ export function useNavigationTabsStore<T>(
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 = (
Expand Down
77 changes: 46 additions & 31 deletions packages/widget/src/stores/navigationTabs/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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([])
Expand All @@ -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(
Expand All @@ -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()
})
})
118 changes: 28 additions & 90 deletions packages/widget/src/stores/navigationTabs/utils.ts
Original file line number Diff line number Diff line change
@@ -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<InternalNavigationTabKey, NavigationTab> = {
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<SplitNavigationTabKey, NavigationTab>

/** Combined lookup across configured and split tabs. */
const tabByKey: Record<NavigationTabKey, NavigationTab> = {
...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
}
Expand All @@ -75,7 +27,7 @@ export const getNavigationTabKeys = (
config.mode === 'split' &&
typeof config.modeOptions?.split !== 'string'
) {
return splitTabKeys
return splitTabs
}
return []
}
Expand All @@ -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
}
Loading
Loading