import { html } from "lit";
import { consume } from "@lit/context";
import { BaseElement } from "../../core/base-element.ts";
import { keyboardRouterContext } from "../keyboard-context.ts";
import type { KeyboardRouter, ShortcutSpec } from "../keyboard-router.ts";
/**
* CTKeybind - Declarative keyboard shortcut listener
*
* @element ct-keybind
*
* @attr {string} name - Optional name for the binding
* @attr {string} code - KeyboardEvent.code (e.g. "KeyO", "ArrowUp")
* @attr {string} key - KeyboardEvent.key (fallback when code not set)
* @attr {boolean} alt - Require Alt/Option
* @attr {boolean} ctrl - Require Control
* @attr {boolean} meta - Require Meta/Cmd
* @attr {boolean} shift - Require Shift
* @attr {boolean} ignoreEditable - Ignore when focus is in inputs
* @attr {boolean} preventDefault - Call preventDefault() on match
* @attr {boolean} stopPropagation - Call stopPropagation() on match
* @attr {boolean} allowRepeat - Allow AutoRepeat (default: false)
*
* @fires ct-keybind - Fired when the binding matches. Detail includes:
* { name?, event, code, key, alt, ctrl, meta, shift }
*
* @example
*
*
*/
export class CTKeybind extends BaseElement {
static override properties = {
name: { type: String },
code: { type: String },
key: { type: String },
alt: { type: Boolean, reflect: true },
ctrl: { type: Boolean, reflect: true },
meta: { type: Boolean, reflect: true },
shift: { type: Boolean, reflect: true },
ignoreEditable: { type: Boolean, attribute: "ignore-editable" },
preventDefault: { type: Boolean, attribute: "prevent-default" },
stopPropagation: { type: Boolean, attribute: "stop-propagation" },
allowRepeat: { type: Boolean, attribute: "allow-repeat" },
} as const;
declare name?: string;
declare code?: string;
declare key?: string;
declare alt: boolean;
declare ctrl: boolean;
declare meta: boolean;
declare shift: boolean;
declare ignoreEditable: boolean;
declare preventDefault: boolean;
declare stopPropagation: boolean;
declare allowRepeat: boolean;
// Track modifier state for layouts that emit separate key events
#altDown = false;
#ctrlDown = false;
#metaDown = false;
#shiftDown = false;
// Optional router provided by host app
@consume({ context: keyboardRouterContext, subscribe: false })
private _router?: KeyboardRouter;
#dispose?: () => void;
constructor() {
super();
this.alt = false;
this.ctrl = false;
this.meta = false;
this.shift = false;
this.ignoreEditable = true;
this.preventDefault = false;
this.stopPropagation = false;
this.allowRepeat = false;
}
override connectedCallback(): void {
super.connectedCallback();
if (this._router) {
const spec = this.#toSpec();
this.#dispose = this._router.register(spec, (e) => this.#emitMatch(e));
} else {
// Fallback: internal listeners if no router provided
document.addEventListener("keydown", this.#onKeyDown, true);
document.addEventListener("keyup", this.#onKeyUp, true);
}
}
override disconnectedCallback(): void {
if (this.#dispose) {
this.#dispose();
this.#dispose = undefined;
} else {
document.removeEventListener("keydown", this.#onKeyDown, true);
document.removeEventListener("keyup", this.#onKeyUp, true);
}
super.disconnectedCallback();
}
override render() {
// Non-visual helper component
return html`
`;
}
#onKeyUp = (e: KeyboardEvent) => {
this.#updateModifiers(e, false);
};
#onKeyDown = (e: KeyboardEvent) => {
this.#updateModifiers(e, true);
if (!this.#matchesContext(e)) return;
const matched = this.#matchesKey(e) && this.#matchesModifiers();
if (!matched) return;
if (!this.allowRepeat && e.repeat) return;
this.#emitMatch(e);
};
#emitMatch(e: KeyboardEvent) {
if (this.preventDefault) e.preventDefault();
if (this.stopPropagation) e.stopPropagation();
this.emit("ct-keybind", {
name: this.name,
event: e,
code: e.code,
key: e.key,
alt: e.altKey,
ctrl: e.ctrlKey,
meta: e.metaKey,
shift: e.shiftKey,
});
}
#matchesContext(e: KeyboardEvent): boolean {
if (!this.ignoreEditable) return true;
const t = e.target as HTMLElement | null;
const tag = (t?.tagName || "").toLowerCase();
const editable = !!(t && (
t.isContentEditable || tag === "input" || tag === "textarea" ||
tag === "select"
));
return !editable;
}
#normalizeKey(k?: string): string | undefined {
if (!k) return undefined;
return k.length === 1 ? k.toLowerCase() : k;
}
#matchesKey(e: KeyboardEvent): boolean {
if (this.code) {
return e.code === this.code;
}
const expected = this.#normalizeKey(this.key);
if (!expected) return false;
const actual = this.#normalizeKey(e.key);
return actual === expected;
}
#matchesModifiers(): boolean {
if (this.alt !== this.#altDown) return false;
if (this.ctrl !== this.#ctrlDown) return false;
if (this.meta !== this.#metaDown) return false;
if (this.shift !== this.#shiftDown) return false;
return true;
}
#updateModifiers(e: KeyboardEvent, down: boolean) {
switch (e.key) {
case "Alt":
this.#altDown = down;
break;
case "Control":
this.#ctrlDown = down;
break;
case "Meta":
this.#metaDown = down;
break;
case "Shift":
this.#shiftDown = down;
break;
default: {
// Also mirror modifier flags from event state
this.#altDown = e.altKey;
this.#ctrlDown = e.ctrlKey;
this.#metaDown = e.metaKey;
this.#shiftDown = e.shiftKey;
}
}
}
#toSpec(): ShortcutSpec {
return {
name: this.name,
code: this.code,
key: this.key,
alt: this.alt,
ctrl: this.ctrl,
meta: this.meta,
shift: this.shift,
ignoreEditable: this.ignoreEditable,
allowRepeat: this.allowRepeat,
preventDefault: this.preventDefault,
stopPropagation: this.stopPropagation,
};
}
}
globalThis.customElements.define("ct-keybind", CTKeybind);