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
2 changes: 1 addition & 1 deletion src/app/app.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
/* Change the grid layout when we hit a certain size. */
@media (min-width: g.$breakpoint-large) {
:host {
grid-template-columns: min-content auto;
grid-template-columns: min-content 1fr;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there an issue with using auto? Iirc, this would fill any empty space, which I can't remember if I wanted or not.

grid-template-rows: #{g.$tab-bar-h} auto #{g.$status-line-h};
}

Expand Down
33 changes: 30 additions & 3 deletions src/app/components/tab-bar/tab-bar.component.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
@for (tab of tabs(); track $index) {
<div class="tab clickable" [class.focused]="tab.focused">
<div class="click-nav" (click)="focusTab(tab)">
<div
class="tab clickable"
[class.focused]="tab.focused"
(mouseenter)="showTooltip(tab, $event)"
(mouseleave)="hideTooltip()"
[id]="'tab-item-' + tab.id"
>
<div
class="click-nav"
(click)="focusTab(tab)"
tabindex="0"
(keydown.enter)="focusTab(tab)"
(keydown.space)="focusTab(tab); $event.preventDefault()"
>
<div class="icon"></div>
<div class="label">{{ tab.label }}</div>
</div>
<div class="x-container">
<button type="button" (click)="closeTab(tab.id)">&#x2715;</button>
<button
type="button"
(click)="closeTab(tab.id); $event.stopPropagation()"
[id]="'tab-close-' + tab.id"
>
<fa-icon [icon]="closeIcon"></fa-icon>
</button>
</div>
</div>
}

<div
class="tab-tooltip"
[class.visible]="tooltipData().visible"
[style.left.px]="tooltipData().x"
[style.top.px]="tooltipData().y"
>
{{ tooltipData().text }}
</div>
39 changes: 38 additions & 1 deletion src/app/components/tab-bar/tab-bar.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
60 changes: 59 additions & 1 deletion src/app/components/tab-bar/tab-bar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*/
Expand Down Expand Up @@ -56,6 +78,13 @@ export class TabBarComponent {
return result;
});

tooltipData = signal<TooltipData>({
text: '',
x: 0,
y: 0,
visible: false
});

/**
* Hide the tabs bar if the screen size is small and there are no applications running.
*/
Expand All @@ -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<HTMLElement>('.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);
}
Expand All @@ -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);
}
}
121 changes: 119 additions & 2 deletions src/app/services/application/application.service.spec.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did the tests require the PLATFORM_ID be injected? I thought they would automatically inject browser.

Original file line number Diff line number Diff line change
@@ -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<NavigationEnd>;
let navigateSpy: ReturnType<typeof vi.fn>;
let navigateByUrlSpy: ReturnType<typeof vi.fn>;

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<NavigationEnd>();
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);
});
});
Loading