Ship prerendered SEO, crawl files and palette/map a11y so every locale route is indexable, honest and keyboard-usable without inventing claims. Co-authored-by: Cursor <cursoragent@cursor.com>
116 lines
2.7 KiB
TypeScript
116 lines
2.7 KiB
TypeScript
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
|
import {
|
|
afterNextRender,
|
|
DestroyRef,
|
|
inject,
|
|
Injectable,
|
|
Injector,
|
|
PLATFORM_ID,
|
|
signal,
|
|
} from '@angular/core';
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class CommandPaletteService {
|
|
private readonly document = inject(DOCUMENT);
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly injector = inject(Injector);
|
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
|
|
readonly open = signal(false);
|
|
readonly dialogId = 'command-palette-dialog';
|
|
readonly titleId = 'command-palette-title';
|
|
readonly descriptionId = 'command-palette-description';
|
|
readonly inputId = 'command-palette-input';
|
|
readonly suggestionsId = 'command-palette-suggestions';
|
|
|
|
private opener: HTMLElement | null = null;
|
|
private previousOverflow = '';
|
|
private scrollLocked = false;
|
|
|
|
constructor() {
|
|
afterNextRender(
|
|
() => {
|
|
if (!this.isBrowser) {
|
|
return;
|
|
}
|
|
|
|
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
|
},
|
|
{ injector: this.injector },
|
|
);
|
|
|
|
this.destroyRef.onDestroy(() => this.teardown());
|
|
}
|
|
|
|
openPalette(opener?: HTMLElement | null): void {
|
|
if (this.open()) {
|
|
return;
|
|
}
|
|
|
|
const active = opener ?? this.document.activeElement;
|
|
this.opener = active instanceof HTMLElement ? active : null;
|
|
this.lockScroll();
|
|
this.open.set(true);
|
|
}
|
|
|
|
close(): void {
|
|
if (!this.open()) {
|
|
return;
|
|
}
|
|
|
|
this.open.set(false);
|
|
this.unlockScroll();
|
|
this.restoreFocus();
|
|
}
|
|
|
|
private restoreFocus(): void {
|
|
const opener = this.opener;
|
|
this.opener = null;
|
|
|
|
afterNextRender(
|
|
() => {
|
|
if (opener instanceof HTMLElement && opener.isConnected) {
|
|
opener.focus();
|
|
}
|
|
},
|
|
{ injector: this.injector },
|
|
);
|
|
}
|
|
|
|
private lockScroll(): void {
|
|
if (!this.isBrowser || this.scrollLocked) {
|
|
return;
|
|
}
|
|
|
|
this.previousOverflow = this.document.body.style.overflow;
|
|
this.document.body.style.overflow = 'hidden';
|
|
this.scrollLocked = true;
|
|
}
|
|
|
|
private unlockScroll(): void {
|
|
if (!this.isBrowser || !this.scrollLocked) {
|
|
return;
|
|
}
|
|
|
|
this.document.body.style.overflow = this.previousOverflow;
|
|
this.previousOverflow = '';
|
|
this.scrollLocked = false;
|
|
}
|
|
|
|
private teardown(): void {
|
|
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
|
this.unlockScroll();
|
|
this.open.set(false);
|
|
this.opener = null;
|
|
}
|
|
|
|
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
|
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
this.openPalette();
|
|
};
|
|
}
|