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
13 changes: 13 additions & 0 deletions src/app/common/panel-layout/panel-collapse-button.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@if (panel?.canCollapse) {
<button
mat-icon-button
type="button"
[attr.aria-expanded]="true"
[attr.aria-label]="'Collapse ' + panel.panelTitle"
[class]="buttonClass"
[matTooltip]="'Collapse ' + panel.panelTitle"
(click)="panel.setCollapsed(true)"
>
<mat-icon aria-hidden="true">{{ panel.collapseIcon }}</mat-icon>
</button>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// The button sits in its neighbours' row as if it were written there directly.
:host {
display: contents;
}
25 changes: 25 additions & 0 deletions src/app/common/panel-layout/panel-collapse-button.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {ChangeDetectionStrategy, Component, Input, inject} from '@angular/core';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {MatTooltipModule} from '@angular/material/tooltip';
import {PanelComponent} from './panel.component';

/**
* Collapses the `app-panel` it sits in, from inside that panel's own content: a list's
* header row, a tab bar. Outside a panel, or when the panel cannot collapse right now
* (stacked, full screen, already a rail), it renders nothing.
*/
@Component({
selector: 'app-panel-collapse-button',
changeDetection: ChangeDetectionStrategy.Eager,
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
host: {class: 'app-panel-collapse-button'},
styleUrl: './panel-collapse-button.component.scss',
templateUrl: './panel-collapse-button.component.html',
})
export class PanelCollapseButtonComponent {
/** Classes for the button, to match the icon buttons beside it. */
@Input() public buttonClass = '';

public readonly panel = inject(PanelComponent, {optional: true});
}
14 changes: 14 additions & 0 deletions src/app/common/panel-layout/panel-fullscreen-button.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@if (visible) {
<button
#toggle
mat-icon-button
type="button"
[attr.aria-label]="label"
[attr.aria-pressed]="isFullscreen"
[class]="buttonClass"
[matTooltip]="label"
(click)="toggleFullscreen()"
>
<mat-icon aria-hidden="true">{{ isFullscreen ? 'close_fullscreen' : 'open_in_full' }}</mat-icon>
</button>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// The button sits in its neighbours' row as if it were written there directly.
:host {
display: contents;
}
86 changes: 86 additions & 0 deletions src/app/common/panel-layout/panel-fullscreen-button.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
ChangeDetectionStrategy,
Component,
DoCheck,
ElementRef,
Input,
ViewChild,
inject,
} from '@angular/core';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {MatTooltipModule} from '@angular/material/tooltip';
import {PanelComponent} from './panel.component';

/**
* Takes the `app-panel` it sits in full screen, from a control row inside that panel's own
* content, such as a tab bar. Outside a panel, or while the panels are stacked one at a
* time on a small screen, it renders nothing.
*
* Leaving full screen by any route (this button, Esc) puts focus back on the button, as
* long as focus was still somewhere in the panel. A swap to another full-screen panel
* leaves focus where the user put it.
*/
@Component({
selector: 'app-panel-fullscreen-button',
changeDetection: ChangeDetectionStrategy.Eager,
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
host: {class: 'app-panel-fullscreen-button'},
styleUrl: './panel-fullscreen-button.component.scss',
templateUrl: './panel-fullscreen-button.component.html',
})
export class PanelFullscreenButtonComponent implements DoCheck {
/** Classes for the button, to match the icon buttons beside it. */
@Input() public buttonClass = '';

public readonly panel = inject(PanelComponent, {optional: true});

@ViewChild('toggle', {read: ElementRef}) private toggle?: ElementRef<HTMLButtonElement>;

private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private wasFullscreen = false;

public get visible(): boolean {
return !!this.panel && !this.panel.stacked;
}

public get isFullscreen(): boolean {
return !!this.panel?.isFullscreen;
}

public get label(): string {
return this.isFullscreen ? 'Exit full screen' : `Open ${this.panel?.panelTitle} full screen`;
}

public ngDoCheck(): void {
const fullscreen = this.isFullscreen;
if (this.wasFullscreen && !fullscreen) {
// After this check has finished, so a blur elsewhere does not land mid-render.
void Promise.resolve().then(() => this.restoreFocus());
}
this.wasFullscreen = fullscreen;
}

public toggleFullscreen(): void {
this.panel?.toggleFullscreen();
}

private restoreFocus(): void {
const button = this.toggle?.nativeElement;
// Gone when the layout left full screen because the panels stacked.
if (!button || !this.host.nativeElement.contains(button)) {
return;
}
const panelElement = this.host.nativeElement.closest('app-panel');
// Another panel went full screen in this one's place, so this one is out of reach.
if (panelElement?.hasAttribute('inert')) {
return;
}
const active = document.activeElement;
const focusWasInPanel =
!active || active === document.body || (!!panelElement && panelElement.contains(active));
if (focusWasInPanel && active !== button) {
button.focus();
}
}
}
21 changes: 21 additions & 0 deletions src/app/common/panel-layout/panel-layout.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@if (stacked && showTabs && panels.length > 1) {
<div aria-label="Panels" class="app-panel-layout__tabs" role="tablist">
@for (panel of panels; track panel.panelId) {
<button
class="app-panel-layout__tab"
role="tab"
type="button"
[attr.aria-selected]="activePanel === panel.panelId"
[attr.data-panel-tab]="panel.panelId"
[class.app-panel-layout__tab--active]="activePanel === panel.panelId"
(click)="selectPanel(panel.panelId)"
>
@if (panel.icon) {
<mat-icon aria-hidden="true">{{ panel.icon }}</mat-icon>
}
<span>{{ panel.panelTitle }}</span>
</button>
}
</div>
}
<ng-content></ng-content>
81 changes: 81 additions & 0 deletions src/app/common/panel-layout/panel-layout.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// The panels sit in an even 12px frame, the same as the gap between them.
:host {
--app-panel-gap: 0.75rem;
position: relative;
display: flex;
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
gap: var(--app-panel-gap);
padding: var(--app-panel-gap);
// Panels keep their minimum widths. The layout rails side panels before that runs
// out, so this only scrolls when the user opens a rail on a page too narrow for it.
overflow-x: auto;
}

:host(.app-panel-layout--stacked) {
flex-direction: column;
overflow: hidden;
}

.app-panel-layout__tabs {
display: flex;
flex: none;
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--ot-color-divider);
border-radius: var(--ot-radius-lg);
background: var(--ot-color-surface);
box-shadow: var(--ot-elevation-1);
}

.app-panel-layout__tab {
display: flex;
min-width: 0;
min-height: 40px;
flex: 1 1 0;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0 0.5rem;
border: 0;
border-radius: var(--ot-radius-md);
background: transparent;
color: var(--ot-color-text-muted);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease;

span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

mat-icon {
width: 20px;
height: 20px;
flex: none;
font-size: 20px;
}

&:hover {
background: var(--ot-color-hover);
}

&:focus-visible {
outline: 2px solid var(--ot-color-focus);
outline-offset: -2px;
}
}

.app-panel-layout__tab--active {
background: var(--ot-color-selected);
color: var(--ot-color-primary);
}
Loading