integration: add route metadata, JSON-LD and harden the public shell
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>
This commit is contained in:
115
src/app/shared/command-palette/command-palette.service.ts
Normal file
115
src/app/shared/command-palette/command-palette.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user