diff --git a/libs/shell/tabs/README.md b/libs/shell/tabs/README.md
new file mode 100644
index 000000000..b6e18217c
--- /dev/null
+++ b/libs/shell/tabs/README.md
@@ -0,0 +1,7 @@
+# shell-tabs
+
+This library was generated with [Nx](https://nx.dev).
+
+## Running unit tests
+
+Run `nx test shell-tabs` to execute the unit tests.
diff --git a/libs/shell/tabs/eslint.config.cjs b/libs/shell/tabs/eslint.config.cjs
new file mode 100644
index 000000000..0d748b70d
--- /dev/null
+++ b/libs/shell/tabs/eslint.config.cjs
@@ -0,0 +1,34 @@
+const nx = require('@nx/eslint-plugin');
+const baseConfig = require('../../../eslint.config.js');
+
+module.exports = [
+ ...baseConfig,
+ ...nx.configs['flat/angular'],
+ ...nx.configs['flat/angular-template'],
+ {
+ files: ['**/*.ts'],
+ rules: {
+ '@angular-eslint/directive-selector': [
+ 'error',
+ {
+ type: 'attribute',
+ prefix: 'shell',
+ style: 'camelCase',
+ },
+ ],
+ '@angular-eslint/component-selector': [
+ 'error',
+ {
+ type: 'element',
+ prefix: 'shell',
+ style: 'kebab-case',
+ },
+ ],
+ },
+ },
+ {
+ files: ['**/*.html'],
+ // Override or add rules here
+ rules: {},
+ },
+];
diff --git a/libs/shell/tabs/project.json b/libs/shell/tabs/project.json
new file mode 100644
index 000000000..630686604
--- /dev/null
+++ b/libs/shell/tabs/project.json
@@ -0,0 +1,20 @@
+{
+ "name": "shell-tabs",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/shell/tabs/src",
+ "prefix": "shell",
+ "projectType": "library",
+ "tags": [],
+ "targets": {
+ "test": {
+ "executor": "@nx/vite:test",
+ "outputs": ["{options.reportsDirectory}"],
+ "options": {
+ "reportsDirectory": "../../../coverage/libs/shell/tabs"
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ }
+ }
+}
diff --git a/libs/shell/tabs/src/index.ts b/libs/shell/tabs/src/index.ts
new file mode 100644
index 000000000..bab95ad96
--- /dev/null
+++ b/libs/shell/tabs/src/index.ts
@@ -0,0 +1 @@
+export * from './lib/shell-tabs.component';
diff --git a/libs/shell/tabs/src/lib/components/shell-tab-item.component.css b/libs/shell/tabs/src/lib/components/shell-tab-item.component.css
new file mode 100644
index 000000000..2d08992ea
--- /dev/null
+++ b/libs/shell/tabs/src/lib/components/shell-tab-item.component.css
@@ -0,0 +1,23 @@
+:host {
+ @apply block opacity-40;
+}
+
+:host.active {
+ @apply opacity-100;
+}
+
+a {
+ @apply h-14;
+}
+
+a.compact {
+ @apply h-[1.625rem] items-center;
+}
+
+button {
+ @apply size-6;
+}
+
+a.compact button {
+ @apply size-[1.625rem];
+}
diff --git a/libs/shell/tabs/src/lib/components/shell-tab-item.component.html b/libs/shell/tabs/src/lib/components/shell-tab-item.component.html
new file mode 100644
index 000000000..1c88c0e18
--- /dev/null
+++ b/libs/shell/tabs/src/lib/components/shell-tab-item.component.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ {{ tab().name }}
+
+ @if (!compact()) {
+
+ {{ tab().subtitle }}
+
+ }
+
+
+
diff --git a/libs/shell/tabs/src/lib/components/shell-tab-item.component.ts b/libs/shell/tabs/src/lib/components/shell-tab-item.component.ts
new file mode 100644
index 000000000..b46bdfe30
--- /dev/null
+++ b/libs/shell/tabs/src/lib/components/shell-tab-item.component.ts
@@ -0,0 +1,48 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ input,
+ inject,
+ computed,
+} from '@angular/core';
+import { Tab } from '@isa/core/tabs';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { isaActionClose } from '@isa/icons';
+import { TabService } from '@isa/core/tabs';
+import { RouterLinkWithHref } from '@angular/router';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'shell-tab-item',
+ templateUrl: './shell-tab-item.component.html',
+ styleUrls: ['./shell-tab-item.component.css'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [NgIcon, RouterLinkWithHref],
+ providers: [provideIcons({ isaActionClose })],
+ host: {
+ '[class.active]': 'active()',
+ },
+})
+export class ShellTabItemComponent {
+ #router = inject(Router);
+ #tabService = inject(TabService);
+
+ tab = input.required();
+
+ compact = input();
+
+ active = computed(() => {
+ const activeId = this.#tabService.activatedTabId();
+ return activeId === this.tab().id;
+ });
+
+ route = computed(() => {
+ const tab = this.tab();
+ return tab.location.locations[tab.location.current];
+ });
+
+ async close() {
+ await this.#router.navigateByUrl('/'); // Navigate away before closing to avoid errors
+ this.#tabService.removeTab(this.tab().id);
+ }
+}
diff --git a/libs/shell/tabs/src/lib/shell-tabs.component.css b/libs/shell/tabs/src/lib/shell-tabs.component.css
new file mode 100644
index 000000000..94410c759
--- /dev/null
+++ b/libs/shell/tabs/src/lib/shell-tabs.component.css
@@ -0,0 +1,3 @@
+:host {
+ @apply block;
+}
diff --git a/libs/shell/tabs/src/lib/shell-tabs.component.html b/libs/shell/tabs/src/lib/shell-tabs.component.html
new file mode 100644
index 000000000..650ad95aa
--- /dev/null
+++ b/libs/shell/tabs/src/lib/shell-tabs.component.html
@@ -0,0 +1,5 @@
+
+ @for (tab of tabs(); track tab.id) {
+
+ }
+
diff --git a/libs/shell/tabs/src/lib/shell-tabs.component.ts b/libs/shell/tabs/src/lib/shell-tabs.component.ts
new file mode 100644
index 000000000..1e3177275
--- /dev/null
+++ b/libs/shell/tabs/src/lib/shell-tabs.component.ts
@@ -0,0 +1,43 @@
+import { Component, inject, computed, signal, ElementRef } from '@angular/core';
+import { ShellTabItemComponent } from './components/shell-tab-item.component';
+import { TabService } from '@isa/core/tabs';
+import { CarouselComponent } from '@isa/ui/carousel';
+
+const PROXIMITY_THRESHOLD_PX = 50;
+
+@Component({
+ selector: 'shell-tabs',
+ imports: [ShellTabItemComponent, CarouselComponent],
+ templateUrl: './shell-tabs.component.html',
+ styleUrl: './shell-tabs.component.css',
+ host: {
+ '(document:mousemove)': 'onMouseMove($event)',
+ },
+})
+export class ShellTabsComponent {
+ #tabService = inject(TabService);
+ #elementRef = inject(ElementRef);
+
+ readonly tabs = this.#tabService.entities;
+
+ #isNear = signal(false);
+
+ compact = computed(() => !this.#isNear());
+
+ onMouseMove(event: MouseEvent): void {
+ const rect = this.#elementRef.nativeElement.getBoundingClientRect();
+ const mouseY = event.clientY;
+ const mouseX = event.clientX;
+
+ const isWithinX = mouseX >= rect.left && mouseX <= rect.right;
+ const distanceY =
+ mouseY < rect.top
+ ? rect.top - mouseY
+ : mouseY > rect.bottom
+ ? mouseY - rect.bottom
+ : 0;
+
+ const isNear = isWithinX && distanceY <= PROXIMITY_THRESHOLD_PX;
+ this.#isNear.set(isNear);
+ }
+}
diff --git a/libs/shell/tabs/src/test-setup.ts b/libs/shell/tabs/src/test-setup.ts
new file mode 100644
index 000000000..cebf5ae72
--- /dev/null
+++ b/libs/shell/tabs/src/test-setup.ts
@@ -0,0 +1,13 @@
+import '@angular/compiler';
+import '@analogjs/vitest-angular/setup-zone';
+
+import {
+ BrowserTestingModule,
+ platformBrowserTesting,
+} from '@angular/platform-browser/testing';
+import { getTestBed } from '@angular/core/testing';
+
+getTestBed().initTestEnvironment(
+ BrowserTestingModule,
+ platformBrowserTesting(),
+);
diff --git a/libs/shell/tabs/tsconfig.json b/libs/shell/tabs/tsconfig.json
new file mode 100644
index 000000000..3268ed4dc
--- /dev/null
+++ b/libs/shell/tabs/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "importHelpers": true,
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "preserve"
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "typeCheckHostBindings": true,
+ "strictTemplates": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/libs/shell/tabs/tsconfig.lib.json b/libs/shell/tabs/tsconfig.lib.json
new file mode 100644
index 000000000..312ee86bb
--- /dev/null
+++ b/libs/shell/tabs/tsconfig.lib.json
@@ -0,0 +1,27 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": [
+ "src/**/*.spec.ts",
+ "src/test-setup.ts",
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx"
+ ],
+ "include": ["src/**/*.ts"]
+}
diff --git a/libs/shell/tabs/tsconfig.spec.json b/libs/shell/tabs/tsconfig.spec.json
new file mode 100644
index 000000000..5785a8a5f
--- /dev/null
+++ b/libs/shell/tabs/tsconfig.spec.json
@@ -0,0 +1,29 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "types": [
+ "vitest/globals",
+ "vitest/importMeta",
+ "vite/client",
+ "node",
+ "vitest"
+ ]
+ },
+ "include": [
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx",
+ "src/**/*.d.ts"
+ ],
+ "files": ["src/test-setup.ts"]
+}
diff --git a/libs/shell/tabs/vite.config.mts b/libs/shell/tabs/vite.config.mts
new file mode 100644
index 000000000..e9b5f10ba
--- /dev/null
+++ b/libs/shell/tabs/vite.config.mts
@@ -0,0 +1,27 @@
+///
+import { defineConfig } from 'vite';
+import angular from '@analogjs/vite-plugin-angular';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
+
+export default defineConfig(() => ({
+ root: __dirname,
+ cacheDir: '../../../node_modules/.vite/libs/shell/tabs',
+ plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
+ // Uncomment this if you are using workers.
+ // worker: {
+ // plugins: [ nxViteTsPaths() ],
+ // },
+ test: {
+ watch: false,
+ globals: true,
+ environment: 'jsdom',
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ setupFiles: ['src/test-setup.ts'],
+ reporters: ['default'],
+ coverage: {
+ reportsDirectory: '../../../coverage/libs/shell/tabs',
+ provider: 'v8' as const,
+ },
+ },
+}));
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 49e561422..c7455f52c 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -150,6 +150,7 @@
"@isa/shell/layout": ["libs/shell/layout/src/index.ts"],
"@isa/shell/navigation": ["libs/shell/navigation/src/index.ts"],
"@isa/shell/notifications": ["libs/shell/notifications/src/index.ts"],
+ "@isa/shell/tabs": ["libs/shell/tabs/src/index.ts"],
"@isa/ui/bullet-list": ["libs/ui/bullet-list/src/index.ts"],
"@isa/ui/buttons": ["libs/ui/buttons/src/index.ts"],
"@isa/ui/carousel": ["libs/ui/carousel/src/index.ts"],