Skip to content

Commit 7262aec

Browse files
committed
feat(launchpad): implement spatial arrow navigation
1 parent f7445eb commit 7262aec

File tree

3 files changed

+257
-0
lines changed

3 files changed

+257
-0
lines changed

projects/element-ng/application-header/launchpad/si-launchpad-factory.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
[preserveFragment]="app.extras?.preserveFragment"
6060
[skipLocationChange]="app.extras?.skipLocationChange"
6161
[replaceUrl]="app.extras?.replaceUrl"
62+
(keydown)="handleAppsNavigation($event, $event.currentTarget)"
6263
(favoriteChange)="toggleFavorite(app, $event)"
6364
>
6465
<span app-name>{{ app.name | translate }}</span>
@@ -77,6 +78,7 @@
7778
[external]="app.external"
7879
[iconUrl]="app.iconUrl"
7980
[iconClass]="app.iconClass"
81+
(keydown)="handleAppsNavigation($event, $event.currentTarget)"
8082
(favoriteChange)="toggleFavorite(app, $event)"
8183
>
8284
<span app-name>{{ app.name | translate }}</span>

projects/element-ng/application-header/launchpad/si-launchpad-factory.component.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
output
1414
} from '@angular/core';
1515
import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
16+
import { correctKeyRTL } from '@siemens/element-ng/common';
1617
import { addIcons, elementCancel, elementDown2, SiIconComponent } from '@siemens/element-ng/icon';
1718
import { SiLinkModule } from '@siemens/element-ng/link';
1819
import { SiTranslatePipe, t, TranslatableString } from '@siemens/element-translate-ng/translate';
@@ -139,6 +140,10 @@ export class SiLaunchpadFactoryComponent {
139140
protected readonly activatedRoute = inject(ActivatedRoute, { optional: true });
140141
private header = inject(SiApplicationHeaderComponent);
141142

143+
// Navigation constants for keyboard arrow navigation
144+
private readonly rowTolerance = 10;
145+
private readonly leftTolerance = 20;
146+
142147
protected closeLaunchpad(): void {
143148
this.header.closeLaunchpad();
144149
}
@@ -154,4 +159,119 @@ export class SiLaunchpadFactoryComponent {
154159
protected isCategories(items: App[] | AppCategory[]): items is AppCategory[] {
155160
return items.some(item => 'apps' in item);
156161
}
162+
163+
protected handleAppsNavigation(event: KeyboardEvent, element: EventTarget | null): void {
164+
const correctedKey = correctKeyRTL(event.key);
165+
166+
if (
167+
!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(correctedKey) ||
168+
!element ||
169+
!(element instanceof HTMLElement)
170+
) {
171+
return;
172+
}
173+
174+
const appContainer = element.closest('.d-flex');
175+
if (!appContainer) return;
176+
177+
const enabledApps = Array.from(
178+
appContainer.querySelectorAll(':scope > a[si-launchpad-app]')
179+
) as HTMLElement[];
180+
181+
const currentIndex = enabledApps.indexOf(element);
182+
if (currentIndex === -1) return;
183+
184+
let targetIndex: number;
185+
186+
if (correctedKey === 'ArrowLeft' || correctedKey === 'ArrowRight') {
187+
// Horizontal navigation within the same row
188+
const direction = correctedKey === 'ArrowLeft' ? -1 : 1;
189+
targetIndex = (currentIndex + direction + enabledApps.length) % enabledApps.length;
190+
} else {
191+
// Vertical navigation between rows
192+
targetIndex = this.getVerticalTargetIndex(
193+
enabledApps,
194+
currentIndex,
195+
correctedKey === 'ArrowUp'
196+
);
197+
}
198+
199+
enabledApps[targetIndex]?.focus();
200+
event.preventDefault();
201+
}
202+
203+
private getVerticalTargetIndex(apps: HTMLElement[], currentIndex: number, isUp: boolean): number {
204+
// Cache all bounding rects to avoid multiple expensive DOM calls
205+
const appRects = apps.map(app => ({
206+
element: app,
207+
rect: app.getBoundingClientRect()
208+
}));
209+
210+
const currentRect = appRects[currentIndex].rect;
211+
212+
// Check if layout is single column (mobile/narrow screen)
213+
const alignedApps = appRects.filter(
214+
({ rect }) => Math.abs(rect.left - currentRect.left) <= this.leftTolerance
215+
);
216+
217+
// Single column: use sequential navigation
218+
if (alignedApps.length >= apps.length) {
219+
const direction = isUp ? -1 : 1;
220+
const targetIndex = currentIndex + direction;
221+
222+
return targetIndex < 0 ? apps.length - 1 : targetIndex >= apps.length ? 0 : targetIndex;
223+
}
224+
225+
// Grid layout: use spatial navigation
226+
const currentRowKey = Math.round(currentRect.top / this.rowTolerance) * this.rowTolerance;
227+
228+
// Group apps by rows (excluding current app for target selection)
229+
const rowGroups = new Map<number, typeof appRects>();
230+
let hasMultipleRows = false;
231+
232+
appRects.forEach((appRect, index) => {
233+
const rowKey = Math.round(appRect.rect.top / this.rowTolerance) * this.rowTolerance;
234+
235+
if (rowKey !== currentRowKey) {
236+
hasMultipleRows = true;
237+
}
238+
239+
if (index !== currentIndex) {
240+
if (!rowGroups.has(rowKey)) {
241+
rowGroups.set(rowKey, []);
242+
}
243+
rowGroups.get(rowKey)!.push(appRect);
244+
}
245+
});
246+
247+
// Single row: no vertical movement
248+
if (!hasMultipleRows) {
249+
return currentIndex;
250+
}
251+
252+
// Find target row and closest app
253+
const sortedRowKeys = Array.from(rowGroups.keys()).sort((a, b) => a - b);
254+
const currentRowIndex = sortedRowKeys.indexOf(currentRowKey);
255+
256+
const targetRowKey = isUp
257+
? currentRowIndex > 0
258+
? sortedRowKeys[currentRowIndex - 1]
259+
: sortedRowKeys[sortedRowKeys.length - 1]
260+
: currentRowIndex < sortedRowKeys.length - 1
261+
? sortedRowKeys[currentRowIndex + 1]
262+
: sortedRowKeys[0];
263+
264+
const targetRowApps = rowGroups.get(targetRowKey) ?? [];
265+
266+
if (targetRowApps.length === 0) return currentIndex;
267+
268+
// Find closest app horizontally in target row
269+
const closestApp = targetRowApps.reduce((closest, app) =>
270+
Math.abs(app.rect.left - currentRect.left) < Math.abs(closest.rect.left - currentRect.left)
271+
? app
272+
: closest
273+
);
274+
275+
return apps.indexOf(closestApp.element);
276+
}
157277
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Copyright (c) Siemens 2016 - 2025
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
import { Component, provideZonelessChangeDetection } from '@angular/core';
6+
import { ComponentFixture, TestBed } from '@angular/core/testing';
7+
8+
import { SiApplicationHeaderComponent } from '../si-application-header.component';
9+
import { SiLaunchpadFactoryComponent } from './si-launchpad-factory.component';
10+
11+
describe('SiLaunchpadFactory - Keyboard Navigation', () => {
12+
@Component({
13+
imports: [SiLaunchpadFactoryComponent],
14+
template: `<si-launchpad-factory [apps]="[]" />`
15+
})
16+
class TestHostComponent {}
17+
18+
let fixture: ComponentFixture<TestHostComponent>;
19+
let component: SiLaunchpadFactoryComponent;
20+
21+
beforeEach(() => {
22+
TestBed.configureTestingModule({
23+
imports: [TestHostComponent],
24+
providers: [
25+
{ provide: SiApplicationHeaderComponent, useValue: {} },
26+
provideZonelessChangeDetection()
27+
]
28+
});
29+
30+
fixture = TestBed.createComponent(TestHostComponent);
31+
component = fixture.debugElement.children[0].componentInstance;
32+
});
33+
34+
const createMockApps = (positions: { top: number; left: number }[]): HTMLElement[] => {
35+
return positions.map(({ top, left }) => {
36+
const app = document.createElement('a');
37+
spyOn(app, 'getBoundingClientRect').and.returnValue({
38+
top,
39+
left,
40+
width: 100,
41+
height: 60
42+
} as DOMRect);
43+
return app;
44+
});
45+
};
46+
47+
describe('getVerticalTargetIndex', () => {
48+
describe('single column layout', () => {
49+
const testCases: [number, boolean, number, string][] = [
50+
[2, false, 3, 'down to next'],
51+
[2, true, 1, 'up to previous'],
52+
[5, false, 0, 'down wrap to first'],
53+
[0, true, 5, 'up wrap to last']
54+
];
55+
56+
testCases.forEach(([from, isUp, expected, description]) => {
57+
it(`should navigate from ${from} ${isUp ? 'up' : 'down'} (${description})`, () => {
58+
const mockApps = createMockApps([
59+
{ top: 0, left: 10 },
60+
{ top: 80, left: 10 },
61+
{ top: 160, left: 10 },
62+
{ top: 240, left: 10 },
63+
{ top: 320, left: 10 },
64+
{ top: 400, left: 10 }
65+
]);
66+
67+
const result = (component as any).getVerticalTargetIndex(mockApps, from, isUp);
68+
expect(result).toBe(expected);
69+
});
70+
});
71+
});
72+
73+
describe('grid layout (2x3)', () => {
74+
const gridPositions = [
75+
{ top: 50, left: 10 },
76+
{ top: 50, left: 130 },
77+
{ top: 50, left: 250 }, // Row 0
78+
{ top: 130, left: 10 },
79+
{ top: 130, left: 130 },
80+
{ top: 130, left: 250 } // Row 1
81+
];
82+
83+
const gridTestCases: [number, boolean, number, string][] = [
84+
[4, true, 1, 'up from row 1 to row 0'],
85+
[1, false, 4, 'down from row 0 to row 1'],
86+
[2, false, 5, 'down with exact alignment'],
87+
[1, true, 4, 'up wrap to bottom row'],
88+
[4, false, 1, 'down wrap to top row']
89+
];
90+
91+
gridTestCases.forEach(([from, isUp, expected, description]) => {
92+
it(`should navigate from ${from} ${isUp ? 'up' : 'down'} (${description})`, () => {
93+
const mockApps = createMockApps(gridPositions);
94+
const result = (component as any).getVerticalTargetIndex(mockApps, from, isUp);
95+
expect(result).toBe(expected);
96+
});
97+
});
98+
99+
it('should stay in place for single row', () => {
100+
const singleRowApps = createMockApps([
101+
{ top: 50, left: 10 },
102+
{ top: 50, left: 130 },
103+
{ top: 50, left: 250 }
104+
]);
105+
106+
const result = (component as any).getVerticalTargetIndex(singleRowApps, 1, false);
107+
expect(result).toBe(1);
108+
});
109+
});
110+
111+
describe('tolerance handling', () => {
112+
it('should detect single column within leftTolerance (20px)', () => {
113+
const mockApps = createMockApps([
114+
{ top: 0, left: 10 },
115+
{ top: 80, left: 25 },
116+
{ top: 160, left: 15 }
117+
]);
118+
119+
const result = (component as any).getVerticalTargetIndex(mockApps, 1, false);
120+
expect(result).toBe(2); // Sequential navigation
121+
});
122+
123+
it('should detect same row within rowTolerance (10px)', () => {
124+
const mockApps = createMockApps([
125+
{ top: 50, left: 10 },
126+
{ top: 50, left: 130 },
127+
{ top: 50, left: 250 }
128+
]);
129+
130+
const result = (component as any).getVerticalTargetIndex(mockApps, 1, false);
131+
expect(result).toBe(1); // No movement
132+
});
133+
});
134+
});
135+
});

0 commit comments

Comments
 (0)