diff --git a/src/app/common/panel-layout/panel-collapse-button.component.html b/src/app/common/panel-layout/panel-collapse-button.component.html new file mode 100644 index 0000000000..4d3e819d8a --- /dev/null +++ b/src/app/common/panel-layout/panel-collapse-button.component.html @@ -0,0 +1,13 @@ +@if (panel?.canCollapse) { + +} diff --git a/src/app/common/panel-layout/panel-collapse-button.component.scss b/src/app/common/panel-layout/panel-collapse-button.component.scss new file mode 100644 index 0000000000..39e07a2cbc --- /dev/null +++ b/src/app/common/panel-layout/panel-collapse-button.component.scss @@ -0,0 +1,4 @@ +// The button sits in its neighbours' row as if it were written there directly. +:host { + display: contents; +} diff --git a/src/app/common/panel-layout/panel-collapse-button.component.ts b/src/app/common/panel-layout/panel-collapse-button.component.ts new file mode 100644 index 0000000000..f3b371a125 --- /dev/null +++ b/src/app/common/panel-layout/panel-collapse-button.component.ts @@ -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}); +} diff --git a/src/app/common/panel-layout/panel-fullscreen-button.component.html b/src/app/common/panel-layout/panel-fullscreen-button.component.html new file mode 100644 index 0000000000..14657560ff --- /dev/null +++ b/src/app/common/panel-layout/panel-fullscreen-button.component.html @@ -0,0 +1,14 @@ +@if (visible) { + +} diff --git a/src/app/common/panel-layout/panel-fullscreen-button.component.scss b/src/app/common/panel-layout/panel-fullscreen-button.component.scss new file mode 100644 index 0000000000..39e07a2cbc --- /dev/null +++ b/src/app/common/panel-layout/panel-fullscreen-button.component.scss @@ -0,0 +1,4 @@ +// The button sits in its neighbours' row as if it were written there directly. +:host { + display: contents; +} diff --git a/src/app/common/panel-layout/panel-fullscreen-button.component.ts b/src/app/common/panel-layout/panel-fullscreen-button.component.ts new file mode 100644 index 0000000000..3c4697f48c --- /dev/null +++ b/src/app/common/panel-layout/panel-fullscreen-button.component.ts @@ -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; + + private readonly host = inject>(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(); + } + } +} diff --git a/src/app/common/panel-layout/panel-layout.component.html b/src/app/common/panel-layout/panel-layout.component.html new file mode 100644 index 0000000000..3dd0dd7310 --- /dev/null +++ b/src/app/common/panel-layout/panel-layout.component.html @@ -0,0 +1,21 @@ +@if (stacked && showTabs && panels.length > 1) { +
+ @for (panel of panels; track panel.panelId) { + + } +
+} + diff --git a/src/app/common/panel-layout/panel-layout.component.scss b/src/app/common/panel-layout/panel-layout.component.scss new file mode 100644 index 0000000000..b65bc549a1 --- /dev/null +++ b/src/app/common/panel-layout/panel-layout.component.scss @@ -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); +} diff --git a/src/app/common/panel-layout/panel-layout.component.spec.ts b/src/app/common/panel-layout/panel-layout.component.spec.ts new file mode 100644 index 0000000000..cd21b3995f --- /dev/null +++ b/src/app/common/panel-layout/panel-layout.component.spec.ts @@ -0,0 +1,470 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {BreakpointObserver} from '@angular/cdk/layout'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BehaviorSubject} from 'rxjs'; +import {PanelCollapseButtonComponent} from './panel-collapse-button.component'; +import {PanelFullscreenButtonComponent} from './panel-fullscreen-button.component'; +import {PanelLayoutComponent} from './panel-layout.component'; +import {PanelStateService} from './panel-state.service'; +import {PanelComponent} from './panel.component'; + +// The real template is set in the test module below, so this one stays empty. +@Component({ + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + PanelLayoutComponent, + PanelComponent, + PanelCollapseButtonComponent, + PanelFullscreenButtonComponent, + ], + // eslint-disable-next-line @angular-eslint/component-max-inline-declarations + template: '', +}) +class HostComponent { + public fullscreen: string | null = null; +} + +// A page that keeps the width itself, the way the project dashboard does. The restored +// width has to land here without moving the parent after its own check has run. +@Component({ + changeDetection: ChangeDetectionStrategy.Eager, + imports: [PanelLayoutComponent, PanelComponent], + // eslint-disable-next-line @angular-eslint/component-max-inline-declarations + template: ` + + + + `, +}) +class TwoWayWidthHostComponent { + public width: number | string | null = 300; +} + +const hostTemplate = ` + + +
+

list

+
+ +
+ + +
+ +

work

+
+ +

comments

+
+
+
+ `; + +describe('PanelLayoutComponent', () => { + let stacked$: BehaviorSubject<{matches: boolean; breakpoints: object}>; + let fixture: ComponentFixture; + + const panel = (id: string): HTMLElement => + fixture.nativeElement.querySelector(`app-panel[data-panel-id="${id}"]`); + const button = (id: string, label: string): HTMLButtonElement => + panel(id).querySelector(`button[aria-label="${label}"]`); + + const instance = (id: string): T => + fixture.debugElement.query((el) => el.nativeElement === panel(id)).componentInstance as T; + const layout = (): PanelLayoutComponent => + fixture.debugElement.children[0].componentInstance as PanelLayoutComponent; + + const create = () => { + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + }; + + beforeEach(async () => { + window.localStorage.clear(); + stacked$ = new BehaviorSubject({matches: false, breakpoints: {}}); + await TestBed.configureTestingModule({ + imports: [HostComponent, TwoWayWidthHostComponent], + providers: [{provide: BreakpointObserver, useValue: {observe: () => stacked$}}], + }) + .overrideComponent(HostComponent, {set: {template: hostTemplate}}) + .compileComponents(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + it('collapses a panel to a rail and expands it again', () => { + create(); + const collapse = button('comments', 'Collapse Comments'); + expect(collapse.getAttribute('aria-expanded')).toBe('true'); + + collapse.click(); + fixture.detectChanges(); + + expect(panel('comments').classList).toContain('app-panel--collapsed'); + const rail = button('comments', 'Expand Comments'); + expect(rail.getAttribute('aria-expanded')).toBe('false'); + expect(rail.textContent).toContain('Comments'); + + rail.click(); + fixture.detectChanges(); + + expect(panel('comments').classList).not.toContain('app-panel--collapsed'); + }); + + it('lets a headerless panel collapse from a button inside its own content', () => { + create(); + const collapse = panel('list').querySelector( + '.list-controls button[aria-label="Collapse Tasks"]', + ); + expect(collapse).not.toBeNull(); + expect(collapse.getAttribute('aria-expanded')).toBe('true'); + expect(collapse.classList).toContain('mat-mdc-icon-button'); + const spy = vi.spyOn(instance('list'), 'setCollapsed'); + + collapse.click(); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith(true); + expect(panel('list').classList).toContain('app-panel--collapsed'); + expect(panel('work').querySelector('button[aria-label^="Collapse"]')).toBeNull(); + }); + + it('has no floating edge toggle on any panel', () => { + create(); + expect(fixture.nativeElement.querySelector('.app-panel__edge-toggle')).toBeNull(); + const buttons = panel('list').querySelectorAll('button[aria-label="Collapse Tasks"]'); + expect(buttons.length).toBe(1); + }); + + it('renders the collapse button as nothing while the panels are stacked', () => { + create(); + stacked$.next({matches: true, breakpoints: {}}); + fixture.detectChanges(); + expect(panel('list').querySelector('button[aria-label="Collapse Tasks"]')).toBeNull(); + }); + + it('keeps a resized panel between its minimum and the room the others leave', () => { + create(); + const list = instance('list'); + vi.spyOn(list, 'currentWidth', 'get').mockReturnValue(290); + list.resizeWithKeyboard(new KeyboardEvent('keydown', {key: 'ArrowLeft', shiftKey: true})); + expect(list.width).toBe(280); + + // 1100 wide less work (420), comments (320) and two 12px gaps leaves 336 for the list. + layout().setAvailableWidth(1100); + vi.spyOn(list, 'currentWidth', 'get').mockReturnValue(330); + list.resizeWithKeyboard(new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true})); + expect(list.width).toBe(336); + expect(panel('list').style.minWidth).toBe('280px'); + }); + + it('does not strand a drag when a second pointer grabs the same handle', () => { + // The teardown for a drag lives in one field. A second pointerdown used to + // overwrite it, leaving the first drag's document listeners on the page for + // good, still resizing the panel from a pointer with no button held. Touch + // reports button 0 for every finger, so this is a two-finger grab. + create(); + const list = instance('list'); + const added: string[] = []; + const removed: string[] = []; + vi.spyOn(document, 'addEventListener').mockImplementation(((type: string) => { + added.push(type); + }) as never); + vi.spyOn(document, 'removeEventListener').mockImplementation(((type: string) => { + removed.push(type); + }) as never); + + const grab = () => new PointerEvent('pointerdown', {button: 0, clientX: 500}); + list.startResize(grab()); + list.startResize(grab()); + + // Whatever the second grab added, the first grab's listeners are gone too. + expect(added.filter((type) => type === 'pointermove').length).toBe(2); + expect(removed.filter((type) => type === 'pointermove').length).toBe(1); + expect(list.resizing).toBe(true); + + vi.mocked(document.addEventListener).mockRestore(); + vi.mocked(document.removeEventListener).mockRestore(); + }); + + it('clamps a remembered width that is out of range when it loads', () => { + window.localStorage.setItem('ontrack.panels.spec.list', '{"width":90}'); + window.localStorage.setItem('ontrack.panels.spec.comments', '{"width":9000}'); + create(); + expect(panel('list').style.width).toBe('280px'); + expect(instance('comments').width).toBe(640); + }); + + it('hands a restored width to a two-way parent after its check, never inside it', async () => { + window.localStorage.setItem('ontrack.panels.spec.list', '{"width":420}'); + const host = TestBed.createComponent(TwoWayWidthHostComponent); + + // Emitting straight from ngOnInit moved the parent's `width` after Angular had + // already read it for the `[width]` binding, which is NG0100 in dev mode. + expect(() => host.detectChanges()).not.toThrow(); + expect(host.componentInstance.width).toBe(300); + + await host.whenStable(); + host.detectChanges(); + + expect(host.componentInstance.width).toBe(420); + expect(host.nativeElement.querySelector('app-panel').style.width).toBe('420px'); + }); + + it('drops a restored width when the panel goes before the check it waits for', async () => { + window.localStorage.setItem('ontrack.panels.spec.list', '{"width":420}'); + const host = TestBed.createComponent(TwoWayWidthHostComponent); + host.detectChanges(); + host.destroy(); + + await host.whenStable(); + + expect(host.componentInstance.width).toBe(300); + }); + + it('rails the list, then comments, when the page is too narrow, without touching storage', async () => { + create(); + await Promise.resolve(); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + + // All three need 280 + 420 + 320 + 24 = 1044. + layout().setAvailableWidth(1000); + fixture.detectChanges(); + expect(panel('list').classList).toContain('app-panel--collapsed'); + expect(panel('comments').classList).not.toContain('app-panel--collapsed'); + expect(instance('list').collapsed).toBe(false); + + layout().setAvailableWidth(700); + fixture.detectChanges(); + expect(panel('comments').classList).toContain('app-panel--collapsed'); + + layout().setAvailableWidth(1200); + fixture.detectChanges(); + expect(panel('list').classList).not.toContain('app-panel--collapsed'); + expect(panel('comments').classList).not.toContain('app-panel--collapsed'); + expect(setItem).not.toHaveBeenCalled(); + expect(window.localStorage.getItem('ontrack.panels.spec.list')).toBeNull(); + }); + + it('opens a space-railed panel from its rail without remembering it', async () => { + create(); + await Promise.resolve(); + layout().setAvailableWidth(1000); + fixture.detectChanges(); + + button('list', 'Expand Tasks').click(); + fixture.detectChanges(); + expect(panel('list').classList).not.toContain('app-panel--collapsed'); + expect(window.localStorage.getItem('ontrack.panels.spec.list')).toBeNull(); + }); + + it('remembers collapsed state and width under a page and panel key', () => { + create(); + button('comments', 'Collapse Comments').click(); + const list = fixture.debugElement.children[0].children[0].componentInstance as PanelComponent; + list.width = 360; + list.resizeWithKeyboard(new KeyboardEvent('keydown', {key: 'ArrowRight'})); + + expect(JSON.parse(window.localStorage.getItem('ontrack.panels.spec.comments'))).toEqual({ + collapsed: true, + }); + expect( + JSON.parse(window.localStorage.getItem('ontrack.panels.spec.list')).width, + ).toBeGreaterThan(0); + + fixture.destroy(); + create(); + + expect(panel('comments').classList).toContain('app-panel--collapsed'); + expect(panel('list').style.width).not.toBe('300px'); + }); + + it('falls back to defaults when storage throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('denied'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('denied'); + }); + + create(); + expect(panel('comments').classList).not.toContain('app-panel--collapsed'); + expect(panel('list').style.width).toBe('300px'); + + expect(() => { + button('comments', 'Collapse Comments').click(); + fixture.detectChanges(); + }).not.toThrow(); + expect(panel('comments').classList).toContain('app-panel--collapsed'); + }); + + it('ignores stored values of the wrong shape', () => { + const service = TestBed.inject(PanelStateService); + window.localStorage.setItem('ontrack.panels.spec.comments', '{"collapsed":"yes","width":-4}'); + expect(service.read('spec', 'comments')).toEqual({}); + window.localStorage.setItem('ontrack.panels.spec.comments', 'not json'); + expect(service.read('spec', 'comments')).toEqual({}); + }); + + it('goes full screen, makes the rest inert, and leaves on Esc', () => { + create(); + button('comments', 'Full screen Comments').click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.fullscreen).toBe('comments'); + expect(panel('comments').classList).toContain('app-panel--fullscreen'); + expect(panel('list').hasAttribute('inert')).toBe(true); + expect(button('comments', 'Exit full screen').getAttribute('aria-pressed')).toBe('true'); + + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'})); + fixture.detectChanges(); + + expect(fixture.componentInstance.fullscreen).toBeNull(); + expect(panel('list').hasAttribute('inert')).toBe(false); + }); + + describe('full-screen button', () => { + const fullscreenButton = (): HTMLButtonElement => + panel('work').querySelector('.work-tabs app-panel-fullscreen-button button'); + + it('toggles the panel it sits in, with a label, pressed state and icon to match', () => { + create(); + const toggle = fullscreenButton(); + const spy = vi.spyOn(instance('work'), 'toggleFullscreen'); + + expect(toggle.getAttribute('aria-label')).toBe('Open Selected task full screen'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + expect(toggle.classList).toContain('mat-mdc-icon-button'); + expect(toggle.classList).toContain('work-fullscreen'); + expect(toggle.textContent.trim()).toBe('open_in_full'); + + toggle.click(); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.fullscreen).toBe('work'); + expect(panel('work').classList).toContain('app-panel--fullscreen'); + expect(panel('list').hasAttribute('inert')).toBe(true); + expect(toggle.getAttribute('aria-label')).toBe('Exit full screen'); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + expect(toggle.textContent.trim()).toBe('close_fullscreen'); + + toggle.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.fullscreen).toBeNull(); + expect(panel('work').classList).not.toContain('app-panel--fullscreen'); + }); + + it('renders nothing outside a panel', () => { + create(); + const outside = fixture.nativeElement.querySelector('.outside-panel'); + expect(outside.querySelector('app-panel-fullscreen-button')).not.toBeNull(); + expect(outside.querySelector('button')).toBeNull(); + }); + + it('renders nothing while the panels are stacked', () => { + create(); + stacked$.next({matches: true, breakpoints: {}}); + fixture.detectChanges(); + expect(fullscreenButton()).toBeNull(); + }); + + it('leaves full screen on Esc and puts focus back on the button', async () => { + create(); + fullscreenButton().click(); + fixture.detectChanges(); + panel('work').querySelector('.work-action').focus(); + expect(document.activeElement).toBe(panel('work').querySelector('.work-action')); + + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'})); + fixture.detectChanges(); + // Focus moves once the check that saw full screen end has finished. + await Promise.resolve(); + + expect(fixture.componentInstance.fullscreen).toBeNull(); + expect(panel('work').classList).not.toContain('app-panel--fullscreen'); + expect(document.activeElement).toBe(fullscreenButton()); + }); + + it('keeps one panel full screen at a time, swapping from comments to the task', async () => { + create(); + const comments = button('comments', 'Full screen Comments'); + comments.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.fullscreen).toBe('comments'); + + fullscreenButton().click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fullscreen).toBe('work'); + expect(fixture.nativeElement.querySelectorAll('.app-panel--fullscreen').length).toBe(1); + expect(panel('comments').classList).not.toContain('app-panel--fullscreen'); + expect(panel('comments').hasAttribute('inert')).toBe(true); + expect(panel('work').hasAttribute('inert')).toBe(false); + + button('comments', 'Full screen Comments').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fullscreen).toBe('comments'); + expect(fixture.nativeElement.querySelectorAll('.app-panel--fullscreen').length).toBe(1); + expect(fullscreenButton().getAttribute('aria-pressed')).toBe('false'); + }); + }); + + it('shows one panel at a time behind tabs when stacked, and ignores collapse', () => { + window.localStorage.setItem('ontrack.panels.spec.comments', '{"collapsed":true}'); + create(); + stacked$.next({matches: true, breakpoints: {}}); + fixture.detectChanges(); + + const tabs = fixture.nativeElement.querySelectorAll('[role="tab"]'); + expect(tabs.length).toBe(3); + expect(panel('list').classList).not.toContain('app-panel--hidden'); + expect(panel('comments').classList).toContain('app-panel--hidden'); + + (tabs[2] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(panel('comments').classList).not.toContain('app-panel--hidden'); + expect(panel('comments').classList).not.toContain('app-panel--collapsed'); + expect(panel('list').classList).toContain('app-panel--hidden'); + }); +}); diff --git a/src/app/common/panel-layout/panel-layout.component.ts b/src/app/common/panel-layout/panel-layout.component.ts new file mode 100644 index 0000000000..a5b656d258 --- /dev/null +++ b/src/app/common/panel-layout/panel-layout.component.ts @@ -0,0 +1,264 @@ +import {BreakpointObserver} from '@angular/cdk/layout'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + NgZone, + OnDestroy, + OnInit, + Output, + forwardRef, + inject, +} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import {Subject, takeUntil} from 'rxjs'; +import {PANEL_LAYOUT, PanelLayoutHost, PanelRegistration} from './panel-layout.token'; + +/** + * Rounded panels side by side with even gaps: a list, the work, and a side panel such as + * comments. Each `app-panel` inside can collapse to a rail, be resized, or go full screen. + * + * Below the stack breakpoint only one panel shows at a time, picked from a row of tabs, so + * nothing scrolls sideways on a small screen. + * + * Above it, every expanded panel keeps its minimum width. When the page cannot fit them + * all, the lowest-priority side panels (the first collapsible ones, so the list and then + * comments) show as rails until there is room again. That is the layout's call alone and + * never written over the user's remembered choice. + */ +@Component({ + selector: 'app-panel-layout', + templateUrl: './panel-layout.component.html', + styleUrl: './panel-layout.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIconModule], + providers: [{provide: PANEL_LAYOUT, useExisting: forwardRef(() => PanelLayoutComponent)}], +}) +export class PanelLayoutComponent implements PanelLayoutHost, OnInit, AfterViewInit, OnDestroy { + /** The width of a collapsed panel's rail, matching `.app-panel--collapsed`. */ + public static readonly railWidth = 44; + + /** Namespaces the remembered panel state, as in `ontrack.panels..`. */ + @Input({required: true}) public page: string; + + /** The width, in pixels, at and below which the panels become tabs. */ + @Input() public stackBelow = 899.98; + + /** Hide the tab row when the page switches panels itself. */ + @Input() public showTabs = true; + + @Input() public fullscreenPanel: string | null = null; + @Output() public fullscreenPanelChange: EventEmitter = new EventEmitter(); + + /** The one panel shown while stacked. Falls back to the first registered panel. */ + @Input() public set activePanel(panelId: string | null) { + this._activePanel = panelId; + } + public get activePanel(): string | null { + const panels = this.panels; + if (this._activePanel && panels.some((panel) => panel.panelId === this._activePanel)) { + return this._activePanel; + } + return panels[0]?.panelId ?? null; + } + @Output() public activePanelChange: EventEmitter = new EventEmitter(); + + public stacked = false; + public panels: PanelRegistration[] = []; + + /** The width the panels have to share, or null before it has been measured. */ + public availableWidth: number | null = null; + + private _activePanel: string | null = null; + private autoRailed: ReadonlySet = new Set(); + private readonly releasedRails: Set = new Set(); + private resizeObserver: ResizeObserver | null = null; + private readonly breakpointObserver = inject(BreakpointObserver); + private readonly changeDetector = inject(ChangeDetectorRef); + private readonly host = inject>(ElementRef); + private readonly zone = inject(NgZone); + private readonly destroy$: Subject = new Subject(); + + @HostBinding('class.app-panel-layout--stacked') + public get stackedClass(): boolean { + return this.stacked; + } + + @HostBinding('class.app-panel-layout--fullscreen') + public get fullscreenClass(): boolean { + return !!this.fullscreenPanel; + } + + public ngOnInit(): void { + this.breakpointObserver + .observe(`(max-width: ${this.stackBelow}px)`) + .pipe(takeUntil(this.destroy$)) + .subscribe(({matches}) => { + this.stacked = matches; + this.changeDetector.markForCheck(); + if (matches && this.fullscreenPanel) { + this.setFullscreen(null); + } + this.updateAutoRails(); + this.notifyResize(); + }); + } + + public ngAfterViewInit(): void { + if (typeof ResizeObserver === 'undefined') { + return; + } + this.resizeObserver = new ResizeObserver((entries) => { + const width = entries[entries.length - 1]?.contentRect.width; + if (typeof width === 'number') { + this.zone.run(() => this.setAvailableWidth(width)); + } + }); + this.resizeObserver.observe(this.host.nativeElement); + } + + public ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.destroy$.next(); + this.destroy$.complete(); + } + + public register(panel: PanelRegistration): void { + if (!this.panels.includes(panel)) { + this.panels = [...this.panels, panel]; + this.scheduleAutoRails(); + } + } + + public unregister(panel: PanelRegistration): void { + this.panels = this.panels.filter((candidate) => candidate !== panel); + this.releasedRails.delete(panel.panelId); + this.scheduleAutoRails(); + if (this.fullscreenPanel === panel.panelId) { + this.setFullscreen(null); + } + } + + public toggleFullscreen(panelId: string): void { + this.setFullscreen(this.fullscreenPanel === panelId ? null : panelId); + } + + public exitFullscreen(): void { + this.setFullscreen(null); + } + + public selectPanel(panelId: string): void { + this._activePanel = panelId; + this.activePanelChange.emit(panelId); + this.notifyResize(); + } + + public setAvailableWidth(width: number): void { + this.availableWidth = Math.max(0, width); + this.updateAutoRails(); + } + + public isAutoRailed(panelId: string): boolean { + return this.autoRailed.has(panelId); + } + + public releaseAutoRail(panelId: string): void { + this.releasedRails.add(panelId); + this.updateAutoRails(); + this.notifyResize(); + } + + public maxWidthFor(panel: PanelRegistration): number { + if (this.availableWidth === null || this.stacked) { + return Number.POSITIVE_INFINITY; + } + const others = this.panels + .filter((candidate) => candidate !== panel) + .reduce((sum, candidate) => sum + this.widthNeeded(candidate, this.autoRailed), 0); + return this.availableWidth - others - this.gapTotal(); + } + + /** + * Rails the first collapsible panels, in order, until every expanded panel fits at its + * minimum. A panel the user opened from its rail is skipped until everything fits. + */ + public updateAutoRails(): void { + const next: Set = new Set(); + const available = this.availableWidth; + if (available !== null && !this.stacked && !this.fullscreenPanel) { + if (this.requiredWidth(next) <= available) { + this.releasedRails.clear(); + } + const candidates = this.panels.filter( + (panel) => panel.collapsible && !panel.collapsed && !this.releasedRails.has(panel.panelId), + ); + for (const panel of candidates) { + if (this.requiredWidth(next) <= available) { + break; + } + next.add(panel.panelId); + } + } + const changed = + next.size !== this.autoRailed.size || [...next].some((id) => !this.autoRailed.has(id)); + if (changed) { + this.autoRailed = next; + this.changeDetector.markForCheck(); + } + } + + @HostListener('document:keydown.escape', ['$event']) + public onEscape(event: Event): void { + // A dialog or menu that handled Esc first keeps it. + if (!this.fullscreenPanel || event.defaultPrevented) { + return; + } + this.setFullscreen(null); + } + + /** PDF viewers and charts measure themselves on window resize. */ + public notifyResize(): void { + window.dispatchEvent(new Event('resize')); + } + + private scheduleAutoRails(): void { + // Panels register while the view is being checked, so settle the rails just after. + Promise.resolve().then(() => this.updateAutoRails()); + } + + private requiredWidth(railed: ReadonlySet): number { + return ( + this.panels.reduce((sum, panel) => sum + this.widthNeeded(panel, railed), 0) + this.gapTotal() + ); + } + + private widthNeeded(panel: PanelRegistration, railed: ReadonlySet): number { + const rail = panel.collapsible && (panel.collapsed || railed.has(panel.panelId)); + return rail ? PanelLayoutComponent.railWidth : panel.minWidth; + } + + private gapTotal(): number { + const count = this.panels.length; + if (count < 2) { + return 0; + } + const gap = parseFloat(getComputedStyle(this.host.nativeElement).columnGap); + return (Number.isFinite(gap) ? gap : 12) * (count - 1); + } + + private setFullscreen(panelId: string | null): void { + if (this.fullscreenPanel === panelId) { + return; + } + this.fullscreenPanel = panelId; + this.fullscreenPanelChange.emit(panelId); + this.updateAutoRails(); + this.notifyResize(); + } +} diff --git a/src/app/common/panel-layout/panel-layout.token.ts b/src/app/common/panel-layout/panel-layout.token.ts new file mode 100644 index 0000000000..7bb12f4f27 --- /dev/null +++ b/src/app/common/panel-layout/panel-layout.token.ts @@ -0,0 +1,33 @@ +import {InjectionToken} from '@angular/core'; + +/** The slice of the layout a panel talks to. Kept in its own file so the two components do not import each other. */ +export interface PanelLayoutHost { + readonly page: string; + readonly stacked: boolean; + readonly fullscreenPanel: string | null; + readonly activePanel: string | null; + register(panel: PanelRegistration): void; + unregister(panel: PanelRegistration): void; + toggleFullscreen(panelId: string): void; + notifyResize(): void; + /** Re-check whether every expanded panel still fits, after a panel collapses or expands. */ + updateAutoRails(): void; + /** True while the layout shows this panel as a rail only because the page is too narrow. */ + isAutoRailed(panelId: string): boolean; + /** Opens a panel the layout railed for space. It stays open until everything fits again. */ + releaseAutoRail(panelId: string): void; + /** The widest this panel can grow before another panel would drop below its minimum. */ + maxWidthFor(panel: PanelRegistration): number; +} + +export interface PanelRegistration { + readonly panelId: string; + readonly panelTitle: string; + readonly icon: string | null; + readonly collapsible: boolean; + /** The user's own choice, as remembered. Not set by the layout's space rule. */ + readonly collapsed: boolean; + readonly minWidth: number; +} + +export const PANEL_LAYOUT: InjectionToken = new InjectionToken('PANEL_LAYOUT'); diff --git a/src/app/common/panel-layout/panel-state.service.ts b/src/app/common/panel-layout/panel-state.service.ts new file mode 100644 index 0000000000..355baa4a7a --- /dev/null +++ b/src/app/common/panel-layout/panel-state.service.ts @@ -0,0 +1,55 @@ +import {Injectable} from '@angular/core'; + +/** What a panel remembers between visits. Anything missing falls back to the page's default. */ +export interface PanelState { + collapsed?: boolean; + width?: number; +} + +/** + * Remembers each panel's collapsed state and width in this browser only. + * + * Storage can be missing or refuse writes (private windows, blocked site data, a full + * quota), so every read and write is guarded and a failure just means the defaults. + */ +@Injectable({providedIn: 'root'}) +export class PanelStateService { + public static readonly prefix = 'ontrack.panels'; + + public key(page: string, panel: string): string { + return `${PanelStateService.prefix}.${page}.${panel}`; + } + + public read(page: string, panel: string): PanelState { + try { + const raw = window.localStorage.getItem(this.key(page, panel)); + if (!raw) { + return {}; + } + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + const value = parsed as Record; + const state: PanelState = {}; + if (typeof value.collapsed === 'boolean') { + state.collapsed = value.collapsed; + } + if (typeof value.width === 'number' && Number.isFinite(value.width) && value.width > 0) { + state.width = value.width; + } + return state; + } catch { + return {}; + } + } + + public write(page: string, panel: string, patch: PanelState): void { + try { + const next = {...this.read(page, panel), ...patch}; + window.localStorage.setItem(this.key(page, panel), JSON.stringify(next)); + } catch { + // Nothing to do: the panel keeps working, it just will not be remembered. + } + } +} diff --git a/src/app/common/panel-layout/panel.component.html b/src/app/common/panel-layout/panel.component.html new file mode 100644 index 0000000000..18e6a48831 --- /dev/null +++ b/src/app/common/panel-layout/panel.component.html @@ -0,0 +1,85 @@ +@if (showRail) { + +} + + +
+ @if (showHeader) { +
+ @if (icon) { + + } +

+ {{ panelTitle }} + +

+ @if (count !== null) { + {{ count }} + } + + @if (fullscreenable && !stacked) { + + } + @if (canCollapse) { + + } +
+ } +
+ +
+
+ +@if (canResize) { + +} diff --git a/src/app/common/panel-layout/panel.component.scss b/src/app/common/panel-layout/panel.component.scss new file mode 100644 index 0000000000..57c8d76d1e --- /dev/null +++ b/src/app/common/panel-layout/panel.component.scss @@ -0,0 +1,213 @@ +// The host is a plain flex item; the card inside carries the look and clips its +// content, so the resize handle can sit out in the gap. +:host { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + flex: 0 1 auto; +} + +:host(.app-panel--flex) { + flex: 1 1 0; +} + +:host(.app-panel--stacked) { + width: 100%; + flex: 1 1 0; +} + +:host(.app-panel--hidden) { + display: none; +} + +:host(.app-panel--collapsed) { + width: 44px; + flex: none; +} + +// Full screen covers the other panels inside the layout's frame, so the app header +// and the page's action bar stay where they are. +:host(.app-panel--fullscreen) { + position: absolute; + inset: var(--app-panel-gap, 0.75rem); + z-index: 270; + width: auto; + animation: app-panel-fullscreen-in 200ms cubic-bezier(0.23, 1, 0.32, 1); +} + +// Opening settles in from just under full size. Leaving is instant, back to the columns. +@keyframes app-panel-fullscreen-in { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + :host(.app-panel--fullscreen) { + animation: none; + } +} + +.app-panel__card { + display: flex; + width: 100%; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; + 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__card--hidden { + display: none; +} + +.app-panel__header { + display: flex; + height: 48px; + flex: none; + align-items: center; + gap: 0.5rem; + padding-inline: 0.75rem 0.25rem; + border-bottom: 1px solid var(--ot-color-divider); +} + +.app-panel__icon { + width: 20px; + height: 20px; + flex: none; + color: var(--ot-color-text-muted); + font-size: 20px; + line-height: 20px; +} + +.app-panel__title { + overflow: hidden; + min-width: 0; + flex: 1; + margin: 0; + color: var(--ot-color-text); + font-size: 1rem; + font-weight: 600; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-panel__count { + min-width: 1.5rem; + flex: none; + padding: 0.125rem 0.375rem; + border-radius: var(--ot-radius-pill); + background: var(--ot-color-selected); + color: var(--ot-color-primary); + font-size: 0.75rem; + font-weight: 600; + text-align: center; +} + +.app-panel__body { + display: flex; + min-width: 0; + min-height: 0; + flex: 1 1 0; + flex-direction: column; + overflow: hidden; +} + +// Collapsed: a slim rail with the icon and the title running down it. +.app-panel__rail { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 0; + border: 1px solid var(--ot-color-divider); + border-radius: var(--ot-radius-lg); + background: var(--ot-color-surface); + box-shadow: var(--ot-elevation-1); + color: var(--ot-color-text-muted); + font: inherit; + cursor: pointer; + transition: + background-color 120ms ease, + color 120ms ease; + + &:hover { + background: var(--ot-color-hover); + color: var(--ot-color-text); + } + + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; + } +} + +.app-panel__rail-icon { + width: 20px; + height: 20px; + flex: none; + font-size: 20px; +} + +.app-panel__rail-title { + overflow: hidden; + color: var(--ot-color-text); + font-size: 0.875rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + writing-mode: vertical-rl; +} + +// The gap beside the panel can be dragged. Its line is only a hint until hovered. +.app-panel__resizer { + position: absolute; + top: 0; + bottom: 0; + z-index: 200; + width: var(--app-panel-gap, 0.75rem); + cursor: col-resize; + touch-action: none; + + &::after { + content: ''; + position: absolute; + top: 25%; + bottom: 25%; + left: calc(50% - 1px); + width: 2px; + border-radius: 1px; + background-color: transparent; + transition: background-color 120ms ease; + } + + &:hover::after, + &:focus-visible::after, + &.app-panel__resizer--active::after { + background-color: var(--ot-color-primary); + } + + &:focus-visible { + outline: none; + } +} + +.app-panel__resizer--end { + right: calc(-1 * var(--app-panel-gap, 0.75rem)); +} + +.app-panel__resizer--start { + left: calc(-1 * var(--app-panel-gap, 0.75rem)); +} diff --git a/src/app/common/panel-layout/panel.component.ts b/src/app/common/panel-layout/panel.component.ts new file mode 100644 index 0000000000..9bf910bc1f --- /dev/null +++ b/src/app/common/panel-layout/panel.component.ts @@ -0,0 +1,309 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + HostBinding, + Input, + OnDestroy, + OnInit, + Output, + inject, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {PANEL_LAYOUT, PanelRegistration} from './panel-layout.token'; +import {PanelStateService} from './panel-state.service'; + +/** + * One rounded card in an `app-panel-layout`. Project actions into `[panelActions]` and a + * note beside the title into `[panelSubtitle]`; everything else becomes the body. + * + * A panel without a header puts an `app-panel-collapse-button` or an + * `app-panel-fullscreen-button` in its content's own control row, which finds this panel + * and collapses it or takes it full screen. + */ +@Component({ + selector: 'app-panel', + templateUrl: './panel.component.html', + styleUrl: './panel.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatButtonModule, MatIconModule, MatTooltipModule], +}) +export class PanelComponent implements PanelRegistration, OnInit, OnDestroy { + @Input({required: true}) public panelId: string; + @Input({required: true}) public panelTitle: string; + @Input() public icon: string | null = null; + @Input() public count: number | null = null; + + /** Without a header, the content carries an `app-panel-collapse-button` instead. */ + @Input() public showHeader = true; + @Input() public collapsible = true; + @Input() public fullscreenable = false; + + /** Takes the space the other panels leave. A flex panel has no width and cannot resize. */ + @Input() public flex = false; + + /** A number is pixels, a string any CSS width. A remembered width wins over it. */ + @Input() public width: number | string | null = null; + /** Resizing, restored widths and the layout's space rule all keep the panel at least this wide. */ + @Input() public minWidth = 200; + @Input() public maxWidth = 640; + + /** Which edge carries the drag handle, the one facing the flexible panel. */ + @Input() public resizeEdge: 'start' | 'end' | null = null; + + /** Starts collapsed when the user has no remembered choice for this panel. */ + @Input() public defaultCollapsed = false; + + @Input() public bodyClass = ''; + + @Output() public widthChange: EventEmitter = new EventEmitter(); + @Output() public collapsedChange: EventEmitter = new EventEmitter(); + + public collapsed = false; + public resizing = false; + + private readonly layout = inject(PANEL_LAYOUT, {optional: true}); + private readonly state = inject(PanelStateService); + private readonly host = inject>(ElementRef); + private removeDragListeners: (() => void) | null = null; + private destroyed = false; + + @HostBinding('class.app-panel') public readonly baseClass = true; + @HostBinding('attr.role') public readonly role = 'region'; + + @HostBinding('attr.aria-label') + public get ariaLabel(): string { + return this.panelTitle; + } + + @HostBinding('attr.data-panel-id') + public get dataPanelId(): string { + return this.panelId; + } + + public get stacked(): boolean { + return !!this.layout?.stacked; + } + + @HostBinding('class.app-panel--fullscreen') + public get isFullscreen(): boolean { + return !!this.panelId && this.layout?.fullscreenPanel === this.panelId; + } + + /** Railed by the layout because the page is too narrow, not by the user. */ + public get autoRailed(): boolean { + return !!this.panelId && !!this.layout?.isAutoRailed(this.panelId); + } + + /** Collapsing is a desktop idea: stacked panels already show one at a time. */ + @HostBinding('class.app-panel--collapsed') + public get showRail(): boolean { + return ( + this.collapsible && (this.collapsed || this.autoRailed) && !this.stacked && !this.isFullscreen + ); + } + + /** Whether a collapse control makes sense right now, in the header or the content. */ + public get canCollapse(): boolean { + return this.collapsible && !this.showRail && !this.stacked && !this.isFullscreen; + } + + @HostBinding('class.app-panel--flex') + public get flexClass(): boolean { + return this.flex; + } + + @HostBinding('class.app-panel--stacked') + public get stackedClass(): boolean { + return this.stacked; + } + + @HostBinding('class.app-panel--hidden') + public get hidden(): boolean { + return this.stacked && this.layout?.activePanel !== this.panelId; + } + + /** Everything behind a full-screen panel is out of reach of Tab. */ + @HostBinding('attr.inert') + public get inert(): '' | null { + const fullscreen = this.layout?.fullscreenPanel; + return fullscreen && fullscreen !== this.panelId ? '' : null; + } + + @HostBinding('style.width') + public get hostWidth(): string | null { + if (this.flex || this.stacked || this.isFullscreen || this.showRail || this.width == null) { + return null; + } + return typeof this.width === 'number' ? `${this.width}px` : this.width; + } + + @HostBinding('style.min-width') + public get hostMinWidth(): string | null { + if (this.stacked || this.isFullscreen || this.showRail) { + return null; + } + return `${this.minWidth}px`; + } + + public get canResize(): boolean { + return !!this.resizeEdge && !this.flex && !this.stacked && !this.isFullscreen && !this.showRail; + } + + public get collapseEdge(): 'start' | 'end' { + return this.resizeEdge ?? 'end'; + } + + public get collapseIcon(): string { + return this.collapseEdge === 'end' ? 'chevron_left' : 'chevron_right'; + } + + public get currentWidth(): number { + return Math.round(this.host.nativeElement.getBoundingClientRect().width); + } + + public ngOnInit(): void { + this.layout?.register(this); + if (!this.layout) { + return; + } + const saved = this.state.read(this.layout.page, this.panelId); + if (this.collapsible) { + this.collapsed = + typeof saved.collapsed === 'boolean' ? saved.collapsed : this.defaultCollapsed; + } + if (this.resizeEdge && !this.flex && saved.width) { + this.width = this.clamp(saved.width); + // A panel runs its ngOnInit inside the check that set its inputs, so emitting here + // changes parent state Angular has already read, which is NG0100 under `[(width)]` + // or any listener that feeds a binding. Hand the width over just after the check + // instead, the way the layout settles its rails. + void Promise.resolve().then(() => this.emitRestoredWidth()); + } + } + + public ngOnDestroy(): void { + this.destroyed = true; + this.layout?.unregister(this); + this.stopDragging(); + } + + /** The parent still has to hear it: the layout's space arithmetic uses the width it holds. */ + private emitRestoredWidth(): void { + if (this.destroyed || typeof this.width !== 'number') { + return; + } + this.widthChange.emit(this.width); + } + + public setCollapsed(collapsed: boolean): void { + if (!this.collapsible || this.collapsed === collapsed) { + return; + } + this.collapsed = collapsed; + this.collapsedChange.emit(collapsed); + if (this.layout) { + this.state.write(this.layout.page, this.panelId, {collapsed}); + this.layout.updateAutoRails(); + this.layout.notifyResize(); + } + } + + /** The rail's button: undo the user's collapse, or open a panel the layout railed for space. */ + public expandFromRail(): void { + if (this.collapsed) { + this.setCollapsed(false); + } else if (this.autoRailed) { + this.layout?.releaseAutoRail(this.panelId); + } + } + + public toggleCollapsed(): void { + this.setCollapsed(!this.collapsed); + } + + public toggleFullscreen(): void { + this.layout?.toggleFullscreen(this.panelId); + } + + public startResize(event: PointerEvent): void { + if (!this.canResize || event.button !== 0) { + return; + } + // Only one drag owns the panel. The teardown for a drag lives in a single + // field, so a second pointerdown before the first pointerup used to + // overwrite it and strand three document listeners: the abandoned move + // handler kept its own startX and startWidth and went on resizing the panel + // from a pointer with no button held, for the rest of the session. Touch + // reports button 0 for every finger, so the guard above does not cover it. + this.stopDragging(); + event.preventDefault(); + const startX = event.clientX; + const startWidth = this.currentWidth; + const direction = this.resizeEdge === 'end' ? 1 : -1; + let frame = 0; + + const move = (moveEvent: PointerEvent) => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + this.width = this.clamp(startWidth + (moveEvent.clientX - startX) * direction); + this.layout?.notifyResize(); + }); + }; + const up = () => { + cancelAnimationFrame(frame); + this.stopDragging(); + this.commitWidth(); + }; + + this.resizing = true; + document.body.classList.add('split-pane-resizing'); + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + document.addEventListener('pointercancel', up); + this.removeDragListeners = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + document.removeEventListener('pointercancel', up); + }; + } + + public resizeWithKeyboard(event: KeyboardEvent): void { + const step = event.shiftKey ? 64 : 16; + const grow = this.resizeEdge === 'end' ? 'ArrowRight' : 'ArrowLeft'; + const shrink = this.resizeEdge === 'end' ? 'ArrowLeft' : 'ArrowRight'; + if (event.key !== grow && event.key !== shrink) { + return; + } + event.preventDefault(); + this.width = this.clamp(this.currentWidth + (event.key === grow ? step : -step)); + this.commitWidth(); + } + + private commitWidth(): void { + if (typeof this.width !== 'number') { + return; + } + this.widthChange.emit(this.width); + if (this.layout) { + this.state.write(this.layout.page, this.panelId, {width: this.width}); + this.layout.notifyResize(); + } + } + + private stopDragging(): void { + this.resizing = false; + this.removeDragListeners?.(); + this.removeDragListeners = null; + document.body.classList.remove('split-pane-resizing'); + } + + private clamp(width: number): number { + const room = this.layout ? this.layout.maxWidthFor(this) : Number.POSITIVE_INFINITY; + const max = Math.min(this.maxWidth, room); + return Math.round(Math.max(this.minWidth, Math.min(max, width))); + } +}