Skip to content
Merged
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
172 changes: 137 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# route-with-dynamic-outlets

[![Build Status][build-img]][build-url]
[![npm version][npm-img]][npm-url]
[![Downloads][downloads-img]][downloads-url]
[![Issues][issues-img]][issues-url]
[![Code Coverage][codecov-img]][codecov-url]
[![Semantic Release][semantic-release-img]][semantic-release-url]
[![Commitizen Friendly][commitizen-img]][commitizen-url]
[![Demo](https://img.shields.io/badge/demo-GitHub%20Pages-8a63f7?logo=github)](https://skitionek.github.io/route-with-dynamic-outlets/)

Some bigger web applications provide panels/tabs interfaces. So far the implementations which I have seen were based on singular route with implementations which comes on top of it. While this solution works for simple case it cripples all advanced features which angular router has to offer (especially nested routes). This library leverages angular router outlets to address previous drawbacks.
Currently it is rather proof of concept but it shows clean direction on how advanced panels/tabs interfaces can be implemented.
Larger Angular applications often need panels or tabs interfaces where each panel is driven by the URL. Most existing implementations use a single route and manage panel state on top of it — this works for simple cases but loses all the advanced features the Angular Router offers (lazy loading, guards, nested routes, etc.).

`route-with-dynamic-outlets` solves this by dynamically registering named `<router-outlet>` children at match time, so every panel is a first-class Angular route. The library is a focused proof of concept that shows a clean, router-native direction for advanced panel/tab UIs.

👉 **[View the interactive demo](https://skitionek.github.io/route-with-dynamic-outlets/)**

Expand All @@ -12,16 +20,16 @@ Currently it is rather proof of concept but it shows clean direction on how adva
### Install

```bash
npm install route-with-dynamic-outlets
npm install @skitionek/route-with-dynamic-outlets
```

Local development and CI now target Node.js 22.14 or newer.
Local development and CI target Node.js 22.14 or newer.

### Development container

This repository includes a dev container config in `.devcontainer/devcontainer.json`.
This repository includes a dev container configuration in `.devcontainer/devcontainer.json`.

In VS Code, run:
In VS Code, open the command palette and run:

```text
Dev Containers: Reopen in Container
Expand All @@ -35,48 +43,41 @@ The container uses Node.js 22, installs dependencies with `npm ci`, and forwards
npm run demo
```

Then open:

```text
http://localhost:4173
```

This serves the interactive demo from the `docs/` directory.
Then open <http://localhost:4173>. This serves the interactive demo from the `docs/` directory.

### Debug in VS Code

The workspace includes launch configurations for debugging Jest and individual TypeScript files from VS Code.
The workspace includes launch configurations for debugging Jest and individual TypeScript files:

```text
Debug Current TS File
Debug Jest Tests
Debug Jest Current File
```

Use Run and Debug in VS Code and pick the matching configuration for the file or test suite you want to inspect.
Open the **Run and Debug** panel in VS Code and pick the configuration that matches the file or test suite you want to inspect.

### Usage
## Usage

````ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PlaceholderComponent } from './placeholder/placeholder.component';
import { createRouteWithDynamicOutlets } from '../route-with-dynamic-outlets/route-with-dynamic-outlets';
### 1. Create a component that renders outlets dynamically

```ts
import { Component } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { map, Observable, switchMap } from 'rxjs';
import { CommonModule } from '@angular/common';
import { OutletsMap } from '../../src';
import { OutletsMap } from '@skitionek/route-with-dynamic-outlets';

@Component({
standalone: true,
selector: 'app-dynamic-outlets',
template: ```
<a [routerLink]="[{ outlets: { A: [''], B: [''] } }]">Open A and B</a>
<router-outlet *ngFor="let outlet of outlets$ | async" [name]="outlet">
{{outlet}}
</router-outlet>
```,
template: `
<a [routerLink]="[{ outlets: { A: [''], B: [''] } }]">Open A and B</a>
<router-outlet
*ngFor="let outlet of outlets$ | async"
[name]="outlet"
></router-outlet>
`,
imports: [RouterModule, CommonModule],
})
export class DynamicOutletsComponent {
Expand All @@ -87,10 +88,20 @@ export class DynamicOutletsComponent {
map(outlets => Object.keys(outlets ?? {}))
);
}
```

### 2. Define routes with `createRouteWithDynamicOutlets`

```ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { createRouteWithDynamicOutlets } from '@skitionek/route-with-dynamic-outlets';
import { DynamicOutletsComponent } from './dynamic-outlets.component';
import { PlaceholderComponent } from './placeholder/placeholder.component';

const routes: Routes = [
createRouteWithDynamicOutlets({
path: '',
path: 'workspace',
component: DynamicOutletsComponent,
dynamicOutletFactory: () => ({
path: '',
Expand All @@ -104,16 +115,107 @@ const routes: Routes = [
exports: [RouterModule],
})
export class AppWorkspaceRoutingModule {}
````
```

### Nesting

Routes with dynamic outlets can be nested arbitrarily. Pass the result of an inner `createRouteWithDynamicOutlets` call as the return value of `dynamicOutletFactory`:

```ts
createRouteWithDynamicOutlets({
path: 'a',
component: DynamicOutletsComponent,
dynamicOutletFactory: () =>
createRouteWithDynamicOutlets({
path: 'b',
component: DynamicOutletsComponent,
dynamicOutletFactory: () => ({
path: 'c',
component: PlaceholderComponent,
}),
}),
})
```

### Custom matcher

Supply a `matcher` function instead of (or in addition to) a `path` to match non-standard URL patterns:

```ts
createRouteWithDynamicOutlets({
component: DynamicOutletsComponent,
matcher: url => {
if (url.length === 1 && url[0].path.match(/^@[\w]+$/)) {
return {
consumed: url,
posParams: { username: new UrlSegment(url[0].path.slice(1), {}) },
};
}
return null;
},
dynamicOutletFactory: () => ({
path: '',
component: PlaceholderComponent,
}),
})
```

## How it works

`createRouteWithDynamicOutlets` wraps the Angular `Route` object with a custom `matcher`. Each time the router evaluates the route, the matcher:

1. Runs the underlying path/matcher check (using Angular's built-in `defaultUrlMatcher` algorithm by default).
2. Reads the current named-outlet children from the `UrlSegmentGroup`.
3. Calls `dynamicOutletFactory` for any new outlets and removes routes for outlets that are no longer present.
4. Pushes the updated `OutletsMap` into a `BehaviorSubject` stored on `route.data.outlets$`.

Your component subscribes to `activatedRoute.data.outlets$` and renders a `<router-outlet>` for each key, giving every outlet a full, independent router lifecycle.

## API

### `createRouteWithDynamicOutlets(route: RouteWithDynamicOutlets): Route`

Creates an Angular `Route` with a custom matcher that dynamically manages child outlet routes.

| Parameter | Type | Description |
|-----------|------|-------------|
| `route` | `RouteWithDynamicOutlets` | Angular `Route` extended with `dynamicOutletFactory`. `path` and `matcher` are both optional and behave the same as in a standard `Route`. |

### `RouteWithDynamicOutlets`

Extends Angular's `Route` with one additional field:

| Field | Type | Description |
|-------|------|-------------|
| `dynamicOutletFactory` | `DynamicOutletRouteFactory` | Called once per new outlet name to build the child route definition for that outlet. |

### `DynamicOutletRouteFactory`

```ts
type DynamicOutletRouteFactory = (
segments: UrlSegment[],
group: UrlSegmentGroup,
route: Route,
outlet: string
) => Omit<Route, 'outlet'>;
```

Receives the current URL segments, segment group, parent route, and the outlet name. Returns a partial `Route` (the `outlet` field is set automatically).

### `OutletsMap`

```ts
type OutletsMap = UrlSegmentGroup['children'];
```

Routes with dynamic outlets still can be nested.
A map of outlet name → `UrlSegmentGroup` reflecting the currently active named outlets. Emitted by the `outlets$` observable on `activatedRoute.data`.

[build-img]: https://github.com/Skitionek/route-with-dynamic-outlets/actions/workflows/release.yml/badge.svg
[build-url]: https://github.com/Skitionek/route-with-dynamic-outlets/actions/workflows/release.yml
[downloads-img]: https://img.shields.io/npm/dt/route-with-dynamic-outlets
[downloads-url]: https://www.npmtrends.com/route-with-dynamic-outlets
[npm-img]: https://img.shields.io/npm/v/route-with-dynamic-outlets
[npm-url]: https://www.npmjs.com/package/route-with-dynamic-outlets
[downloads-img]: https://img.shields.io/npm/dt/@skitionek/route-with-dynamic-outlets
[downloads-url]: https://www.npmtrends.com/@skitionek/route-with-dynamic-outlets
[npm-img]: https://img.shields.io/npm/v/@skitionek/route-with-dynamic-outlets
[npm-url]: https://www.npmjs.com/package/@skitionek/route-with-dynamic-outlets
[issues-img]: https://img.shields.io/github/issues/Skitionek/route-with-dynamic-outlets
[issues-url]: https://github.com/Skitionek/route-with-dynamic-outlets/issues
[codecov-img]: https://codecov.io/gh/Skitionek/route-with-dynamic-outlets/branch/main/graph/badge.svg
Expand Down
Loading