npm package: @sigmela/router
Description: React Native Router
Author: https://github.com/bogoslavskiy
Latest version: 0.3.7

# Sigmela Router (`@sigmela/router`)

Modern, lightweight navigation for **React Native** and **Web**, built on top of [`react-native-screens`](https://github.com/software-mansion/react-native-screens).

This library is **URL-first**: you navigate by **paths** (`/users/42?tab=posts`), and the router derives `params` and `query` for screens.

## Features

- **Stacks**: predictable stack-based navigation
- **Tabs**: `TabBar` with native + web renderers (or custom tab bar)
- **Drawer**: side-panel navigation (`Drawer`)
- **Split view**: master/details navigation (`SplitView`)
- **Modals & sheets**: via `stackPresentation` (`modal`, `sheet`, …)
- **Controllers**: async/guarded navigation (only present when ready)
- **Appearance**: global styling via `NavigationAppearance` (tab bar colors, fonts, blur effects, etc.)
- **Web History integration**: keeps Router state in sync with `pushState`, `replaceState`, `popstate`
- **Dynamic root**: swap root navigation tree at runtime (`router.setRoot`)
- **Type-safe hooks**: `useParams`, `useQueryParams`, `useRoute`, `useCurrentRoute`

## Installation

```bash
yarn add @sigmela/router react-native-screens
# optional (required only if you use sheet presentation)
yarn add @sigmela/native-sheet
```

### Peer dependencies

- `react`
- `react-native`
- `react-native-screens` (>= `4.24.0`)
- `@sigmela/native-sheet` (>= `0.0.1`) — only if you use sheets

### Web CSS

On web you must import the bundled stylesheet **once**:

```ts
import '@sigmela/router/styles.css';
```

## Quick start

### Simple stack

```tsx
import {
  Navigation,
  NavigationStack,
  Router,
  useParams,
  useQueryParams,
  useRouter,
} from '@sigmela/router';

function HomeScreen() {
  const router = useRouter();
  return (
    <Button
      title="Open details"
      onPress={() => router.navigate('/details/42?from=home')}
    />
  );
}

function DetailsScreen() {
  const { id } = useParams<{ id: string }>();
  const { from } = useQueryParams<{ from?: string }>();
  return <Text>Details: id={id}, from={from ?? 'n/a'}</Text>;
}

const rootStack = new NavigationStack()
  .addScreen('/', HomeScreen, { header: { title: 'Home' } })
  .addScreen('/details/:id', DetailsScreen, { header: { title: 'Details' } });

const router = new Router({ roots: { app: rootStack }, root: 'app' });

export default function App() {
  return <Navigation router={router} />;
}
```

### Tabs

```tsx
import { Navigation, NavigationStack, Router, TabBar } from '@sigmela/router';

const homeStack = new NavigationStack().addScreen('/', HomeScreen);

const catalogStack = new NavigationStack()
  .addScreen('/catalog', CatalogScreen)
  .addScreen('/catalog/products/:productId', ProductScreen);

const tabBar = new TabBar({ initialIndex: 0 })
  .addTab({ key: 'home', stack: homeStack, title: 'Home' })
  .addTab({ key: 'catalog', stack: catalogStack, title: 'Catalog' });

const router = new Router({ roots: { app: tabBar }, root: 'app' });

export default function App() {
  return <Navigation router={router} />;
}
```

### Tabs wrapped by a root stack (like `example/src/navigation/stacks.ts`)

In the example app, tabs are mounted as a **screen** inside a root `NavigationStack`. This lets you keep tab navigation plus define modals/overlays at the root level.

```tsx
import { NavigationStack, TabBar } from '@sigmela/router';

const homeStack = new NavigationStack().addScreen('/', HomeScreen);
const catalogStack = new NavigationStack().addScreen('/catalog', CatalogScreen);

const tabBar = new TabBar()
  .addTab({
    key: 'home',
    stack: homeStack,
    title: 'Home',
    icon: require('./assets/home.png'),
  })
  .addTab({
    key: 'catalog',
    stack: catalogStack,
    title: 'Catalog',
    icon: require('./assets/catalog.png'),
  });

// Root stack hosts the tab bar AND top-level modals/overlays.
export const rootStack = new NavigationStack()
  .addScreen('/', tabBar)
  .addModal('/auth', AuthScreen, { header: { title: 'Login', hidden: true } })
  .addModal('*?modal=promo', PromoModal);
```

## Core concepts

### `NavigationStack`

A `NavigationStack` defines a set of routes and how to match them.

```tsx
const stack = new NavigationStack({ header: { largeTitle: true } })
  .addScreen('/feed', FeedScreen)
  .addScreen('/feed/:id', FeedItemScreen)
  .addModal('/auth', AuthScreen)
  .addSheet('/settings', SettingsSheet);
```

Key methods:
- `addScreen(pathPattern, componentOrNode, options?)`
- `addModal(pathPattern, componentOrStack, options?)` (shorthand for `stackPresentation: 'modal'`)
- `addSheet(pathPattern, componentOrStack, options?)` (shorthand for `stackPresentation: 'sheet'`)
- `addStack(prefixOrStack, maybeStack?)` — compose nested stacks under a prefix

#### Provider Context

You can wrap an entire stack with a React context provider by passing a `provider` option:

```tsx
import { ThemeProvider } from './theme';

const stack = new NavigationStack({
  header: { largeTitle: true },
  provider: ThemeProvider,
})
  .addScreen('/feed', FeedScreen)
  .addScreen('/feed/:id', FeedItemScreen);
```

The `provider` component wraps the entire stack renderer, making the context available to all screens in the stack. This is useful for:
- **Theme providers**: Apply theme context to all screens
- **Auth providers**: Share authentication state across screens
- **Localization**: Provide i18n context to the entire stack

**Composing multiple providers:**

If you need multiple providers, create a composed component:

```tsx
const ComposedProvider = ({ children }) => (
  <ThemeProvider>
    <AuthProvider>
      <I18nProvider>
        {children}
      </I18nProvider>
    </AuthProvider>
  </ThemeProvider>
);

const stack = new NavigationStack({ provider: ComposedProvider })
  .addScreen('/', HomeScreen);
```

**Important:** The provider should be a stable reference (not an inline arrow function) to avoid unnecessary re-renders.

### Modal Stacks (Stack in Stack)

You can pass an entire `NavigationStack` to `addModal()` or `addSheet()` to create a multi-screen flow inside a modal:

```tsx
// Define a flow with multiple screens
const emailVerifyStack = new NavigationStack()
  .addScreen('/verify', EmailInputScreen)
  .addScreen('/verify/sent', EmailSentScreen);

// Mount the entire stack as a modal
const rootStack = new NavigationStack()
  .addScreen('/', HomeScreen)
  .addModal('/verify', emailVerifyStack);
```

**How it works:**
- Navigating to `/verify` opens the modal with `EmailInputScreen`
- Inside the modal, `router.navigate('/verify/sent')` pushes `EmailSentScreen` within the same modal
- `router.goBack()` navigates back inside the modal stack
- `router.dismiss()` closes the entire modal from any depth

**Example screen with navigation inside modal:**

```tsx
function EmailInputScreen() {
  const router = useRouter();
  
  return (
    <View>
      <Button title="Next" onPress={() => router.navigate('/verify/sent')} />
      <Button title="Close" onPress={() => router.dismiss()} />
    </View>
  );
}

function EmailSentScreen() {
  const router = useRouter();
  
  return (
    <View>
      <Button title="Back" onPress={() => router.goBack()} />
      <Button title="Done" onPress={() => router.dismiss()} />
    </View>
  );
}
```

This pattern works recursively — you can nest stacks inside stacks to any depth.

### `Router`

The `Router` holds navigation state and performs path matching.

```ts
const router = new Router({
  roots: { app: root }, // NavigationNode (NavigationStack, TabBar, SplitView, ...)
  root: 'app',
  screenOptions, // optional defaults
  debug, // optional
});
```

Navigation:
- `router.navigate(path)` — push
- `router.replace(path, dedupe?)` — replace top of the active stack
- `router.goBack()` — pop top of the active stack
- `router.dismiss()` — close the nearest modal or sheet (including all screens in a modal stack)
- `router.reset(path)` — **web-only**: rebuild Router state as if app loaded at `path`
- `router.setRoot(rootKey, { transition? })` — swap root at runtime (`rootKey` from `config.roots`)

State/subscriptions:
- `router.getState()` → `{ history: HistoryItem[] }`
- `router.getActiveRoute()` → `ActiveRoute | null`
- `router.subscribe(cb)` — notify on any history change
- `router.subscribeStack(stackId, cb)` — notify when a particular stack slice changes
- `router.subscribeRoot(cb)` — notify when root is replaced via `setRoot`
- `router.getStackHistory(stackId)` — slice of history for a stack

### `TabBar`

`TabBar` is a container node that renders one tab at a time.

```tsx
const tabBar = new TabBar({ component: CustomTabBar, initialIndex: 0 })
  .addTab({ key: 'home', stack: homeStack, title: 'Home' })
  .addTab({ key: 'search', screen: SearchScreen, title: 'Search' });
```

Key methods:
- `addTab({ key, stack?, node?, screen?, prefix?, title?, icon?, selectedIcon?, ... })`
- `onIndexChange(index)` — switch active tab
- `setBadge(index, badge | null)`
- `setTabBarConfig(partialConfig)`
- `getState()` and `subscribe(cb)`

Notes:
- Exactly one of `stack`, `node`, `screen` must be provided.
- Use `prefix` to mount a tab's routes under a base path (e.g. `/mail`).
- All `TabsScreenProps` from `react-native-screens` are forwarded to native. This includes lifecycle events (`onWillAppear`, `onDidAppear`, `onWillDisappear`, `onDidDisappear`), accessibility props (`testID`, `accessibilityLabel`, `tabBarItemTestID`, `tabBarItemAccessibilityLabel`), `orientation`, `systemItem`, `freezeContents`, `placeholder`, `scrollEdgeEffects`, badge styling, and more.

#### `setTabBarConfig()`

Runtime tab bar configuration:

```tsx
tabBar.setTabBarConfig({
  bottomAccessory: (environment) => <MiniPlayer layout={environment} />,  // iOS 26+
  experimentalControlNavigationStateInJS: true,
});
```

Web behavior note:
- The built-in **web** tab bar renderer resets Router history on tab switch (to keep URL and Router state consistent) using `router.reset(firstRoutePath)`.

### `Drawer`

`Drawer` provides side-panel navigation, similar to `TabBar` but with a slide-out panel.

```tsx
import { Drawer, NavigationStack } from '@sigmela/router';

const homeStack = new NavigationStack().addScreen('/', HomeScreen);
const settingsStack = new NavigationStack().addScreen('/settings', SettingsScreen);

const drawer = new Drawer({ width: 280 })
  .addTab({ key: 'home', stack: homeStack, title: 'Home' })
  .addTab({ key: 'settings', stack: settingsStack, title: 'Settings' });
```

Key methods:
- `addTab({ key, stack?, node?, screen?, prefix?, title?, icon?, ... })`
- `open()`, `close()`, `toggle()` — manage drawer state
- `getIsOpen()` — current open state
- `subscribeOpenState(listener)` — subscribe to open/close changes
- `onIndexChange(index)` — switch active tab
- `setBadge(index, badge | null)`

### `SplitView`

`SplitView` renders **two stacks**: `primary` and `secondary`.

- On **native**, `secondary` overlays `primary` when it has at least one screen in its history.
- On **web**, the layout becomes side-by-side at a fixed breakpoint (`minWidth`, default `640px`).

```tsx
import { NavigationStack, SplitView, TabBar } from '@sigmela/router';

const master = new NavigationStack().addScreen('/', ThreadsScreen);
const detail = new NavigationStack().addScreen('/:threadId', ThreadScreen);

const splitView = new SplitView({
  minWidth: 640,
  primary: master,
  secondary: detail,
  primaryMaxWidth: 390,
});

// Mount SplitView directly as a tab (no wrapper stack needed).
const tabBar = new TabBar()
  .addTab({ key: 'mail', node: splitView, prefix: '/mail', title: 'Mail' })
  .addTab({ key: 'settings', stack: settingsStack, title: 'Settings' });
```

## Controllers

Controllers let you delay/guard navigation. A route can be registered as:

```tsx
import { createController } from '@sigmela/router';

const UserDetails = {
  component: UserDetailsScreen,
  controller: createController<{ userId: string }, { tab?: string }>(
    async ({ params, query }, present) => {
      const ok = await checkAuth();
      if (!ok) {
        router.replace('/login', true);
        return;
      }

      const user = await fetchUser(params.userId);
      present({ user, tab: query.tab });
    }
  ),
};

stack.addScreen('/users/:userId', UserDetails);
```

If you never call `present()`, the screen is not pushed/replaced.

## Appearance

Pass `NavigationAppearance` to `<Navigation>` to customize styling globally:

```tsx
import { Navigation, type NavigationAppearance } from '@sigmela/router';

const appearance: NavigationAppearance = {
  tabBar: {
    backgroundColor: '#ffffff',
    iconColor: '#999999',
    iconColorActive: '#007AFF',
    badgeBackgroundColor: '#FF3B30',
    iOSShadowColor: '#00000020',
    title: {
      fontFamily: 'Inter',
      fontSize: 10,
      color: '#999999',
      activeColor: '#007AFF',
      activeFontSize: 12,       // Android: active tab title font size
    },

    // Android-specific
    androidActiveIndicatorEnabled: true,
    androidActiveIndicatorColor: '#007AFF20',
    androidRippleColor: '#007AFF10',
    labelVisibilityMode: 'labeled',

    // Tab bar behavior
    hidden: false,                           // hide/show the tab bar
    tintColor: '#007AFF',                    // iOS: selected tab tint + glow color
    controllerMode: 'automatic',             // iOS 18+: 'automatic' | 'tabBar' | 'tabSidebar'
    minimizeBehavior: 'automatic',           // iOS 26+: 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp'
    nativeContainerBackgroundColor: '#fff',   // native container background
    iOSBlurEffect: 'systemDefault',          // iOS: tab bar blur effect
  },
  header: { /* ScreenStackHeaderConfigProps */ },
  sheet: {
    cornerRadius: 16,
    backgroundColor: '#ffffff',
  },
};

export default function App() {
  return <Navigation router={router} appearance={appearance} />;
}
```

## Hooks

### `useRouter()`

Access the router instance.

### `useCurrentRoute()`

Subscribes to `router.getActiveRoute()`.

Returns `ActiveRoute | null` (shape from `src/types.ts`):

```ts
type ActiveRoute = {
  routeId: string;
  stackId?: string;
  tabIndex?: number;
  path?: string;
  params?: Record<string, unknown>;
  query?: Record<string, unknown>;
} | null;
```

### `useParams<T>()` / `useQueryParams<T>()`

Returns params/query for the **current screen** (from route context).

### `useRoute()`

Returns route-local context for the current screen:

```ts
type RouteLocalContextValue = {
  presentation: StackPresentationTypes;
  params?: Record<string, unknown>;
  query?: Record<string, unknown>;
  pattern?: string;
  path?: string;
};
```

### `useTabBar()`

Returns the nearest `TabBar` from context (only inside tab screens).

```tsx
import { useTabBar } from '@sigmela/router';

function ScreenInsideTabs() {
  const tabBar = useTabBar();

  return (
    <Button
      title="Go to second tab"
      onPress={() => tabBar.onIndexChange(1)}
    />
  );
}
```

### `useTabBarHeight()`

Returns the tab bar height constant (`57`). Useful for bottom padding.

### `useDrawer()`

Returns the nearest `Drawer` from context (only inside drawer screens).

```tsx
import { useDrawer } from '@sigmela/router';

function ScreenInsideDrawer() {
  const drawer = useDrawer();
  return <Button title="Open menu" onPress={() => drawer.open()} />;
}
```

### `useSplitView()`

Returns the nearest `SplitView` from context (only inside split view screens).

## Web integration

### History API syncing

On web, Router integrates with the browser History API using custom events:

- `router.navigate('/x')` writes `history.pushState({ __srPath: ... })`
- `router.replace('/x')` writes `history.replaceState({ __srPath: ... })`
- Browser back/forward triggers `popstate` and Router updates its state accordingly

Important behavioral detail:
- `router.goBack()` **does not call** `history.back()`. It pops Router state and updates the URL via `replaceState` (so it doesn’t grow/rewind the browser stack).

### `syncWithUrl: false`

If a route has `screenOptions.syncWithUrl = false`, Router stores the “real” router path in `history.state.__srPath` while keeping the visible URL unchanged.

## License

MIT