+
}
+
+
+ {{ tooltipData().text }}
+
diff --git a/src/app/components/tab-bar/tab-bar.component.scss b/src/app/components/tab-bar/tab-bar.component.scss
index d118c635..21fca6f0 100644
--- a/src/app/components/tab-bar/tab-bar.component.scss
+++ b/src/app/components/tab-bar/tab-bar.component.scss
@@ -6,8 +6,15 @@
width: 100%;
height: 100%;
background-color: theme.$bg3;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
+
.label {
overflow: hidden;
text-overflow: ellipsis;
@@ -22,13 +29,15 @@
.tab {
@include g.no-select;
+ flex: 1 1 0;
+ min-width: 5rem;
+ max-width: 12rem;
display: grid;
grid-template-columns: 5fr 2rem;
align-content: center;
background-color: theme.$bg2;
box-shadow: 0px -1px 0px theme.$outline inset;
- width: clamp(9rem, 20vw, 12rem);
& > .x-container {
text-align: center;
@@ -40,6 +49,34 @@
}
}
+.tab-tooltip {
+ position: fixed;
+ transform: translateX(-50%);
+ z-index: 9999;
+ pointer-events: none;
+
+ background-color: theme.$bg1;
+ color: theme.$code-text;
+ font-family: g.$main-font;
+ font-size: g.$small-font-size;
+ white-space: nowrap;
+ padding: 3px 8px;
+ border-radius: 3px;
+ border: 1px solid theme.$outline;
+
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.15s ease,
+ transform 0.12s ease,
+ visibility 0.15s ease;
+
+ &.visible {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
.tab.focused {
background-color: theme.$bg4;
border: none;
diff --git a/src/app/components/tab-bar/tab-bar.component.ts b/src/app/components/tab-bar/tab-bar.component.ts
index 39f8a62a..f06c7ac8 100644
--- a/src/app/components/tab-bar/tab-bar.component.ts
+++ b/src/app/components/tab-bar/tab-bar.component.ts
@@ -2,13 +2,25 @@ import {
ChangeDetectionStrategy,
Component,
computed,
+ effect,
HostBinding,
inject,
- Signal
+ signal,
+ Signal,
+ untracked
} from '@angular/core';
import { ApplicationService } from 'services/application/application.service';
import { UiService } from 'services/ui/ui.service';
import { STRUCTURE_MAP } from 'styles/structure';
+import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
+import { faXmark } from '@fortawesome/free-solid-svg-icons';
+
+interface TooltipData {
+ text: string;
+ x: number;
+ y: number;
+ visible: boolean;
+}
export interface TabBarItem {
label: string;
@@ -19,16 +31,26 @@ export interface TabBarItem {
@Component({
selector: 'cs-tab-bar',
+ imports: [FontAwesomeModule],
templateUrl: './tab-bar.component.html',
styleUrl: './tab-bar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TabBarComponent {
+ protected closeIcon = faXmark;
+
@HostBinding('style.height')
get height(): string {
return this.isHidden() ? '0px' : STRUCTURE_MAP['tab-bar-h'];
}
+ constructor() {
+ effect(() => {
+ this.tabs();
+ untracked(() => this.hideTooltip());
+ });
+ }
+
/**
* Gives the tabs-bar the applications to display.
*/
@@ -56,6 +78,13 @@ export class TabBarComponent {
return result;
});
+ tooltipData = signal
({
+ text: '',
+ x: 0,
+ y: 0,
+ visible: false
+ });
+
/**
* Hide the tabs bar if the screen size is small and there are no applications running.
*/
@@ -64,6 +93,34 @@ export class TabBarComponent {
!this.uiService.isLargeViewport() && this.applicationService.runningApplications().size === 0
);
+ /**
+ * Shows a popup of label when tab is hovered and smaller tabs.
+ */
+ showTooltip(tab: TabBarItem, event: MouseEvent): void {
+ const el = event.currentTarget as HTMLElement;
+ const label = el.querySelector('.label');
+
+ // Only show the tooltip when the label text is actually being clipped.
+ if (!label || label.scrollWidth <= label.clientWidth) {
+ return;
+ }
+
+ const rect = el.getBoundingClientRect();
+ this.tooltipData.set({
+ text: tab.label,
+ x: rect.left + rect.width / 2,
+ y: rect.bottom + 6,
+ visible: true
+ });
+ }
+
+ hideTooltip(): void {
+ this.tooltipData.update(state => ({
+ ...state,
+ visible: false
+ }));
+ }
+
focusTab(tab: TabBarItem): void {
this.applicationService.router.navigateByUrl(tab.route);
}
@@ -74,6 +131,7 @@ export class TabBarComponent {
* @param index - The index of the tabs to close.
*/
closeTab(index: number): void {
+ this.hideTooltip();
this.applicationService.closeApplication(index);
}
}
diff --git a/src/app/services/application/application.service.spec.ts b/src/app/services/application/application.service.spec.ts
index d0bf0f1f..55508441 100644
--- a/src/app/services/application/application.service.spec.ts
+++ b/src/app/services/application/application.service.spec.ts
@@ -1,16 +1,133 @@
+import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-
+import { NavigationEnd, Router } from '@angular/router';
+import { Subject } from 'rxjs';
+import { vi } from 'vitest';
+import { TAB_SESSION_STORAGE_KEY } from './tab-session-state';
import { ApplicationService } from './application.service';
describe('ApplicationService', () => {
let service: ApplicationService;
+ let routerEvents: Subject;
+ let navigateSpy: ReturnType;
+ let navigateByUrlSpy: ReturnType;
+
+ const getStoredApplicationIds = (): number[] => {
+ const raw = localStorage.getItem(TAB_SESSION_STORAGE_KEY);
+ if (!raw) {
+ return [];
+ }
+
+ return JSON.parse(raw).applicationIds as number[];
+ };
+
+ const navigateTo = (url: string): void => {
+ routerEvents.next(new NavigationEnd(1, url, url));
+ };
beforeEach(() => {
- TestBed.configureTestingModule({});
+ localStorage.clear();
+ routerEvents = new Subject();
+ navigateSpy = vi.fn();
+ navigateByUrlSpy = vi.fn();
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy, navigateByUrl: navigateByUrlSpy } },
+ { provide: PLATFORM_ID, useValue: 'browser' }
+ ]
+ });
+
service = TestBed.inject(ApplicationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
+
+ it('should restore valid application IDs from local storage', () => {
+ localStorage.setItem(
+ TAB_SESSION_STORAGE_KEY,
+ JSON.stringify({ version: 1, applicationIds: [0, 1, 999] })
+ );
+
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy, navigateByUrl: navigateByUrlSpy } },
+ { provide: PLATFORM_ID, useValue: 'browser' }
+ ]
+ });
+ service = TestBed.inject(ApplicationService);
+
+ expect([...service.runningApplications().keys()]).toEqual([0, 1]);
+ });
+
+ it('should persist when a new application is opened', () => {
+ navigateTo('/readme');
+
+ expect(getStoredApplicationIds()).toEqual([0]);
+ });
+
+ it('should not persist when switching focus between open applications', () => {
+ navigateTo('/readme');
+ navigateTo('/officers');
+ const persistedAfterBothOpen = localStorage.getItem(TAB_SESSION_STORAGE_KEY);
+
+ navigateTo('/readme');
+
+ expect(localStorage.getItem(TAB_SESSION_STORAGE_KEY)).toBe(persistedAfterBothOpen);
+ expect([...service.runningApplications().keys()]).toEqual([0, 1]);
+ });
+
+ it('should persist when an application is closed', () => {
+ navigateTo('/readme');
+ navigateTo('/officers');
+
+ service.closeApplication(0);
+
+ expect(getStoredApplicationIds()).toEqual([1]);
+ });
+
+ it('should persist an empty tab session when the last application is closed', () => {
+ navigateTo('/readme');
+
+ service.closeApplication(0);
+
+ expect(getStoredApplicationIds()).toEqual([]);
+ expect(navigateSpy).toHaveBeenCalledWith(['/']);
+ });
+
+ it('should navigate to the left neighbor when closing a focused tab', () => {
+ navigateTo('/readme');
+ navigateTo('/officers');
+ navigateTo('/committees');
+
+ service.closeApplication(2);
+
+ expect(navigateByUrlSpy).toHaveBeenCalledWith('/officers');
+ });
+
+ it('should navigate to the right neighbor when there is no left neighbor', () => {
+ navigateTo('/readme');
+ navigateTo('/officers');
+
+ // Focus the leftmost tab so it has no left neighbor.
+ navigateTo('/readme');
+
+ service.closeApplication(0);
+
+ expect(navigateByUrlSpy).toHaveBeenCalledWith('/officers');
+ });
+
+ it('should not navigate when closing a background tab', () => {
+ navigateTo('/readme');
+ navigateTo('/officers');
+
+ service.closeApplication(0);
+
+ expect(navigateByUrlSpy).not.toHaveBeenCalled();
+ expect(navigateSpy).not.toHaveBeenCalled();
+ expect(service.focusedApplication()?.id).toBe(1);
+ });
});
diff --git a/src/app/services/application/application.service.ts b/src/app/services/application/application.service.ts
index 30d314e1..6cad2759 100644
--- a/src/app/services/application/application.service.ts
+++ b/src/app/services/application/application.service.ts
@@ -1,8 +1,15 @@
-import { inject, Injectable, signal, WritableSignal } from '@angular/core';
+import { isPlatformBrowser } from '@angular/common';
+import { inject, Injectable, PLATFORM_ID, signal, WritableSignal } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { addToSignalMap, removeFromSignalMap } from 'utils/signal-utils';
-import { AppInfo, getApplicationByRoute } from './applications';
+import {
+ AppInfo,
+ buildRunningApplicationsFromIds,
+ getApplicationByRoute,
+ shareSameActivityGroup
+} from './applications';
+import { readTabSession, serializeTabSession, writeTabSession } from './tab-session-state';
/**
* Service that handles updating what applications are currently running.
@@ -26,7 +33,13 @@ export class ApplicationService {
router = inject(Router);
+ private platformId = inject(PLATFORM_ID);
+
+ private lastPersisted: string | null = null;
+
constructor() {
+ this.restoreSession();
+
// Observable that emits when navigating to a new URL has completed.
// Used to check if the route should launch an application or change the content of an application.
this.router.events
@@ -48,44 +61,151 @@ export class ApplicationService {
});
}
+ /**
+ * Closes the application based on the unique ID.
+ * When the closed tab was focused, switches to the nearest neighbor.
+ *
+ * @param id - The ID of the application to close.
+ */
+ closeApplication(id: number): void {
+ if (!this.isApplicationRunning(id)) {
+ return;
+ }
+
+ const wasFocused = this.focusedApplication()?.id === id;
+
+ // Determine the adjacent tab before removal so we can switch to it.
+ let nextRoute: string | null = null;
+ if (wasFocused) {
+ const entries = [...this.runningApplications().entries()];
+ const index = entries.findIndex(([appId]) => appId === id);
+
+ if (index > 0) {
+ // Prefer the tab to the left.
+ nextRoute = entries[index - 1][1].route;
+ } else if (index < entries.length - 1) {
+ // Fall back to the tab on the right.
+ nextRoute = entries[index + 1][1].route;
+ }
+ }
+
+ this.removeRunningApplication(id);
+
+ if (wasFocused) {
+ if (nextRoute) {
+ this.router.navigateByUrl(nextRoute);
+ } else {
+ this.focusedApplication.set(null);
+ this.router.navigate(['/']);
+ }
+ }
+ }
+
+ /**
+ * Rebuilds open tabs from the tab session saved in local storage.
+ * Invalid or removed application IDs are skipped.
+ */
+ private restoreSession(): void {
+ if (!isPlatformBrowser(this.platformId)) {
+ return;
+ }
+
+ const session = readTabSession();
+ if (!session) {
+ return;
+ }
+
+ const restoredApplications = buildRunningApplicationsFromIds(session.applicationIds);
+
+ if (restoredApplications.size) {
+ this.runningApplications.set(restoredApplications);
+ this.lastPersisted = serializeTabSession([...restoredApplications.keys()]);
+ }
+ }
+
+ /**
+ * Saves the current open applications to local storage.
+ */
+ private persistRunningApplications(): void {
+ if (!isPlatformBrowser(this.platformId)) {
+ return;
+ }
+
+ const applicationIds = [...this.runningApplications().keys()];
+ const serialized = serializeTabSession(applicationIds);
+
+ if (serialized === this.lastPersisted) {
+ return;
+ }
+
+ writeTabSession(applicationIds);
+ this.lastPersisted = serialized;
+ }
+
/**
* Try to open the application.
*
* @param application - Application to try and launch.
*/
private openApplication(application: AppInfo): void {
- const focusedApp = this.focusedApplication();
+ if (this.isAlreadyFocused(application)) {
+ return;
+ }
- if (focusedApp?.id === application.id) {
+ if (this.isApplicationRunning(application.id)) {
+ this.focusApplication(application);
return;
}
- addToSignalMap(this.runningApplications, application.id, application);
- this.focusedApplication.set(application);
+ this.addRunningApplication(application);
+ }
- // The activity for this app might be open, but not focused. Try and remove it.
- for (const app of this.runningApplications().values()) {
- if (app.activityKey === application.activityKey && app.id !== application.id) {
- this.closeApplication(app.id);
- break;
- }
+ /**
+ * Updates focus without changing which applications are open.
+ */
+ private focusApplication(application: AppInfo): void {
+ if (this.isAlreadyFocused(application)) {
+ return;
}
+
+ this.focusedApplication.set(application);
}
/**
- * Closes the application based on the unique ID.
- *
- * @param id - The ID of the application to close.
+ * Adds an application to the running set and persists the update.
*/
- closeApplication(id: number): void {
+ private addRunningApplication(application: AppInfo): void {
+ addToSignalMap(this.runningApplications, application.id, application);
+ this.focusApplication(application);
+ this.removeConflictingApplications(application);
+ this.persistRunningApplications();
+ }
+
+ /**
+ * Removes an application from the running set and persists the update.
+ */
+ private removeRunningApplication(id: number): void {
removeFromSignalMap(this.runningApplications, id);
- if (this.runningApplications().size) {
- const nextApp = this.runningApplications().entries().next().value;
- if (nextApp) {
- this.openApplication(nextApp[1]);
+ this.persistRunningApplications();
+ }
+
+ /**
+ * Removes applications that cannot run alongside the given application.
+ */
+ private removeConflictingApplications(application: AppInfo): void {
+ for (const app of this.runningApplications().values()) {
+ if (shareSameActivityGroup(app, application) && app.id !== application.id) {
+ removeFromSignalMap(this.runningApplications, app.id);
+ break;
}
- } else {
- this.router.navigate(['/']);
}
}
+
+ private isAlreadyFocused(application: AppInfo): boolean {
+ return this.focusedApplication()?.id === application.id;
+ }
+
+ private isApplicationRunning(id: number): boolean {
+ return this.runningApplications().has(id);
+ }
}
diff --git a/src/app/services/application/applications.ts b/src/app/services/application/applications.ts
index 39cabad1..50bd5bef 100644
--- a/src/app/services/application/applications.ts
+++ b/src/app/services/application/applications.ts
@@ -18,7 +18,8 @@ export interface AppInfo {
/**
* The key activity this applications will use.
- * Applications that have the same key can't run alongside each other.
+ * Applications that share the same non-empty key can't run alongside each other.
+ * An empty key means the application is standalone.
*/
activityKey: string;
@@ -158,3 +159,41 @@ export const getApplicationByRoute = (route: string): AppInfo | undefined => {
}
return;
};
+
+export const isStandaloneActivity = (activityKey: string): boolean => activityKey === '';
+
+export const shareSameActivityGroup = (first: AppInfo, second: AppInfo): boolean => {
+ return !isStandaloneActivity(first.activityKey) && first.activityKey === second.activityKey;
+};
+
+export const buildRunningApplicationsFromIds = (
+ applicationIds: number[]
+): Map => {
+ const runningApplications = new Map();
+
+ for (const id of applicationIds) {
+ if (runningApplications.has(id)) {
+ continue;
+ }
+
+ const application = getApplicationById(id);
+ if (!application) {
+ continue;
+ }
+
+ let hasConflictingActivity = false;
+ for (const existing of runningApplications.values()) {
+ if (shareSameActivityGroup(existing, application)) {
+ hasConflictingActivity = true;
+ break;
+ }
+ }
+ if (hasConflictingActivity) {
+ continue;
+ }
+
+ runningApplications.set(application.id, application);
+ }
+
+ return runningApplications;
+};
diff --git a/src/app/services/application/tab-session-state.ts b/src/app/services/application/tab-session-state.ts
new file mode 100644
index 00000000..4fc0a672
--- /dev/null
+++ b/src/app/services/application/tab-session-state.ts
@@ -0,0 +1,112 @@
+import { error } from "console";
+
+export const TAB_SESSION_STORAGE_KEY = 'csss-tab-session';
+const TAB_SESSION_VERSION = 1;
+
+/**
+ * Persisted tab session stored in local storage.
+ */
+export interface TabSessionState {
+ version: typeof TAB_SESSION_VERSION;
+ applicationIds: number[];
+}
+
+const buildTabSessionState = (applicationIds: number[]): TabSessionState => ({
+ version: TAB_SESSION_VERSION,
+ applicationIds
+});
+
+const isValidApplicationIds = (value: unknown): value is number[] =>
+ Array.isArray(value) && value.every(id => typeof id === 'number' && Number.isInteger(id));
+
+/**
+ * Checks whether a parsed JSON value has the expected tab session shape and values.
+ */
+const isTabSessionState = (value: unknown): value is TabSessionState => {
+ if (typeof value !== 'object' || value === null) {
+ return false;
+ }
+
+ if (!('version' in value) || !('applicationIds' in value)) {
+ return false;
+ }
+
+ const { version, applicationIds } = value as {
+ version: unknown;
+ applicationIds: unknown;
+ };
+
+ return version === TAB_SESSION_VERSION && isValidApplicationIds(applicationIds);
+};
+
+/**
+ * Serializes open application IDs to a JSON string for local storage.
+ *
+ * @param applicationIds - Open application IDs in tab order.
+ */
+export const serializeTabSession = (applicationIds: number[]): string => {
+ return JSON.stringify(buildTabSessionState(applicationIds));
+};
+
+/**
+ * Parses a stored tab session JSON string.
+ *
+ * @param raw - Raw JSON string from local storage.
+ * @returns Parsed state, or null if the value is invalid.
+ */
+export const parseTabSession = (raw: string): TabSessionState | null => {
+ try {
+ const parsed: unknown = JSON.parse(raw);
+
+ if (!isTabSessionState(parsed)) {
+ return null;
+ }
+
+ return buildTabSessionState(parsed.applicationIds);
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Reads the stored tab session from local storage.
+ * If the stored value is corrupt or outdated, the key is removed so it
+ * does not cause repeated parse failures on every subsequent boot.
+ */
+export const readTabSession = (): TabSessionState | null => {
+ if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
+ return null;
+ }
+
+ try {
+ const raw = localStorage.getItem(TAB_SESSION_STORAGE_KEY);
+ if (raw === null) {
+ return null;
+ }
+
+ const parsed = parseTabSession(raw);
+ if (parsed === null) {
+ localStorage.removeItem(TAB_SESSION_STORAGE_KEY);
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Writes open application IDs to local storage.
+ *
+ * @param applicationIds - Open application IDs in tab order.
+ */
+export const writeTabSession = (applicationIds: number[]): void => {
+ if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
+ return;
+ }
+
+ try {
+ localStorage.setItem(TAB_SESSION_STORAGE_KEY, serializeTabSession(applicationIds));
+ } catch (error) {
+ console.warn('Failed to write tab session to local storage', error);
+ }
+};
diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss
index 0305e09d..3cf7f4e6 100644
--- a/src/styles/_fonts.scss
+++ b/src/styles/_fonts.scss
@@ -6,3 +6,4 @@ $h1-font-size: clamp(1.8rem, 2.5vw, 2.2rem);
$h2-font-size: clamp(1.4rem, 2.2vw, 1.8rem);
$h3-font-size: clamp(1.2rem, 2vw, 1.4rem);
$normal-font-size: clamp(1rem, 1.4vw, 1.2rem);
+$small-font-size: clamp(0.8rem, 1.2vw, 1rem);
\ No newline at end of file