invokers
Version:
A powerful, platform-first JavaScript library for creating modern user interfaces with declarative HTML. Features universal command chaining, conditional execution, and declarative workflow orchestration via `data-and-then` attributes and `<and-then>` ele
1,089 lines (992 loc) • 41.4 kB
text/typescript
/**
* @file polyfill.ts
* @summary A high-fidelity polyfill for the W3C/WHATWG HTML Invoker Buttons proposal.
* @license MIT (Assumed from project context)
* @author Keith Cirkel, Luke Warlow (original authors of explainer/polyfill)
* @description This code implements the proposed `command` and `commandfor` attributes for HTMLButtonElement
* and the `CommandEvent` interface, allowing declarative UI interactions in browsers
* that do not yet natively support these features.
* It includes handling for popovers and dialogs, and a mechanism for custom commands.
*/
export function isSupported() {
const target = typeof window !== "undefined" ? window : globalThis;
return (
typeof HTMLButtonElement !== "undefined" &&
"command" in HTMLButtonElement.prototype &&
// @ts-ignore
"source" in ((target.CommandEvent || {}).prototype || {})
);
}
// NOTE: The `isPolyfilled` function from the original polyfill code will not be exported
// directly from invokers itself, but its logic is relevant to understanding when to apply.
// For `invokers`, we will always apply the polyfill to ensure a consistent environment
// and to guarantee the `CommandEvent` and attributes are present, even if native support exists.
function enumerate(obj: object, key: PropertyKey, enumerable: boolean = true) {
Object.defineProperty(obj, key, {
...Object.getOwnPropertyDescriptor(obj, key),
enumerable,
});
}
function getRootNode(node: Node): Node {
if (node && typeof node.getRootNode === "function") {
return node.getRootNode();
}
// Fallback for older environments or unusual node types
if (node && node.parentNode) return getRootNode(node.parentNode);
return node;
}
const commandEventSourceElements = new WeakMap<Event, Element | null>();
const commandEventActions = new WeakMap<Event, string>();
/**
* Represents a declarative `command` event dispatched from an invoker button to its target.
* This class mirrors the proposed W3C/WHATWG `CommandEvent` interface.
*/
declare global {
interface CommandEventInit extends EventInit {
command?: string;
source?: Element;
target?: Element;
}
interface CommandEvent extends Event {
readonly command: string;
readonly source: HTMLButtonElement | null;
}
}
class CommandEventPolyfill extends Event {
constructor(type: string, invokeEventInit: CommandEventInit = {}) {
super(type, invokeEventInit);
const { source, command, target } = invokeEventInit;
if (source != null && typeof source !== 'object') {
throw new TypeError(`source must be an element`);
}
// Additional validation: check if it has element-like properties
if (source != null && (!source.nodeType || !source.tagName)) {
throw new TypeError(`source must be an element`);
}
commandEventSourceElements.set(this, source || null);
commandEventActions.set(
this,
command !== undefined ? String(command) : "",
);
// Store target for later retrieval
if (target) {
(this as any)._commandEventTarget = target;
}
}
get [Symbol.toStringTag]() {
return "CommandEvent";
}
/**
* The element (usually a <button>) that initiated the command.
* If the event's target is in a different root (e.g., Shadow DOM),
* this will return the host of the invoker's root.
*/
get source(): Element | null {
if (!commandEventSourceElements.has(this)) {
throw new TypeError("illegal invocation");
}
const source = commandEventSourceElements.get(this);
if (!source) return null;
// In polyfill environment, trust that source is an element if set
const invokerRoot = getRootNode(source);
// Ensure the source element is within the same document or shadow root context
if (invokerRoot !== getRootNode(this.target as Node || document)) {
// If cross-root, return the shadow host for encapsulation
return invokerRoot instanceof ShadowRoot ? invokerRoot.host : null;
}
return source;
}
/**
* The command string that was invoked (e.g., "show-modal", "--my-custom-command").
*/
get command(): string {
if (!commandEventActions.has(this)) {
throw new TypeError("illegal invocation");
}
return commandEventActions.get(this) || "";
}
/**
* The target element for the command (may differ from event.target).
*/
get targetElement(): Element | null {
return (this as any)._commandEventTarget || null;
}
// Deprecated properties for compatibility with older proposals
get action(): never {
throw new Error(
"CommandEvent#action was renamed to CommandEvent#command",
);
}
get invoker(): never {
throw new Error(
"CommandEvent#invoker was renamed to CommandEvent#source",
);
}
}
enumerate(CommandEventPolyfill.prototype, "source");
enumerate(CommandEventPolyfill.prototype, "command");
// Deprecated InvokeEvent for compatibility
class InvokeEventPolyfill extends Event {
constructor(type: string, invokeEventInit: EventInit = {}) {
super(type, invokeEventInit);
throw new Error(
"InvokeEvent has been deprecated, it has been renamed to `CommandEvent`",
);
}
}
const invokerAssociatedElements = new WeakMap<HTMLElement, Element>();
/**
* Mixin to apply `commandforElement` and `command` properties to `HTMLButtonElement`.
* This extends the `HTMLButtonElement` prototype to include the declarative command API.
* @param ElementClass The HTML element class to extend (e.g., HTMLButtonElement).
*/
function applyInvokerMixin(ElementClass: typeof HTMLElement) {
Object.defineProperties(ElementClass.prototype, {
/**
* Imperatively sets or gets the element controlled by the button.
* Reflects the `commandfor` attribute.
*/
commandForElement: {
enumerable: true,
configurable: true,
set(targetElement: Element | null) {
// Handle deprecated attributes for warnings
if (this.hasAttribute("invokeaction")) {
throw new TypeError(
"Element has deprecated `invokeaction` attribute, replace with `command`",
);
} else if (this.hasAttribute("invoketarget")) {
throw new TypeError(
"Element has deprecated `invoketarget` attribute, replace with `commandfor`",
);
} else if (targetElement === null) {
// If null, remove the attribute and clear associated element
this.removeAttribute("commandfor");
invokerAssociatedElements.delete(this);
} else if (!(targetElement instanceof Element)) {
// Ensure valid element type
throw new TypeError(`commandForElement must be an element or null`);
} else {
// Set attribute (value is irrelevant for IDREF, just presence)
this.setAttribute("commandfor", targetElement.id || ""); // Use ID if available
const targetRootNode = getRootNode(targetElement);
const thisRootNode = getRootNode(this);
// Only associate if in the same document/root, or owner document
if (
thisRootNode === targetRootNode ||
targetRootNode === this.ownerDocument
) {
invokerAssociatedElements.set(this, targetElement);
} else {
invokerAssociatedElements.delete(this); // Remove if cross-root
}
}
},
get(): Element | null {
// Buttons, inputs, and textareas support commandfor
if (this.localName !== "button" && this.localName !== "input" && this.localName !== "textarea") {
return null;
}
// Warn for deprecated attributes
if (
this.hasAttribute("invokeaction") ||
this.hasAttribute("invoketarget")
) {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn(
"Element has deprecated `invoketarget` or `invokeaction` attribute, use `commandfor` and `command` instead",
);
}
return null;
}
// Disabled buttons don't invoke
if (this.disabled) {
return null;
}
// Buttons in forms must be type="button" to use commandfor
if (this.form && this.getAttribute("type") !== "button") {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn(
"Element with `commandfor` is a form participant. " +
"It should explicitly set `type=button` in order for `commandfor` to work",
);
}
return null;
}
// First, check imperatively set element
const targetElement = invokerAssociatedElements.get(this);
if (targetElement) {
if (targetElement.isConnected) {
return targetElement;
} else {
invokerAssociatedElements.delete(this); // Clean up disconnected elements
return null;
}
}
// Fallback to IDREF lookup if not imperatively set
const selector = this.getAttribute("commandfor");
if (!selector) return null;
const root = getRootNode(this) as any;
const doc = this.ownerDocument || (root && root.ownerDocument) || (typeof document !== "undefined" ? document : null);
// First try ID lookup - handle both #id and plain id formats
let idToLookup = selector;
if (selector.startsWith('#')) {
idToLookup = selector.slice(1);
}
// Try direct ID lookup first (most common case)
if (/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(idToLookup)) {
if (root && typeof root.getElementById === "function") {
const element = root.getElementById(idToLookup);
if (element) return element;
}
if (doc && typeof doc.getElementById === "function") {
const element = doc.getElementById(idToLookup);
if (element) return element;
}
}
// Fallback to CSS selector lookup for complex selectors
try {
const cssSelector = selector.startsWith('#') ? selector : '#' + selector;
if (root && typeof root.querySelector === "function") {
const element = root.querySelector(cssSelector);
if (element) return element;
}
if (doc && typeof doc.querySelector === "function") {
const element = doc.querySelector(cssSelector);
if (element) return element;
}
// Try as-is in case it's a class or other selector
if (root && typeof root.querySelector === "function") {
const element = root.querySelector(selector);
if (element) return element;
}
if (doc && typeof doc.querySelector === "function") {
const element = doc.querySelector(selector);
if (element) return element;
}
} catch (e) {
// Invalid selector, return null
}
return null;
},
},
/**
* Gets or sets the command string.
* Handles normalization for built-in commands and enforces `--` prefix for custom commands.
*/
command: {
enumerable: true,
configurable: true,
get(): string {
const value = (this.getAttribute("command") || "").trim();
// Custom commands always return their raw value (starting with --)
if (value.startsWith("--")) return value;
// Built-in commands are normalized to lowercase
const valueLower = value.toLowerCase();
switch (valueLower) {
// Core commands (already implemented)
case "show-modal":
case "close":
case "request-close":
case "toggle-popover":
case "hide-popover":
case "show-popover":
// Future commands - Openable elements
case "toggle-openable":
case "close-openable":
case "open-openable":
// Details elements
case "toggle":
case "open":
// Picker elements
case "show-picker":
// Media elements
case "play-pause":
case "pause":
case "play":
case "toggle-muted":
// Fullscreen elements
case "toggle-fullscreen":
case "request-fullscreen":
case "exit-fullscreen":
// Clipboard and sharing
case "copy-text":
case "share":
// Number input elements
case "step-up":
case "step-down":
return valueLower;
}
return ""; // Invalid command if not built-in or custom
},
set(value: string) {
this.setAttribute("command", value);
},
},
// Deprecated properties for compatibility with older proposals
invokeAction: {
enumerable: false,
configurable: true,
get(): never {
throw new Error(
`invokeAction is deprecated. It has been renamed to command`,
);
},
set(_value: string): never {
throw new Error(
`invokeAction is deprecated. It has been renamed to command`,
);
},
},
invokeTargetElement: {
enumerable: false,
configurable: true,
get(): never {
throw new Error(
`invokeTargetElement is deprecated. It has been renamed to command`,
);
},
set(_value: Element | null): never {
throw new Error(
`invokeTargetElement is deprecated. It has been renamed to command`,
);
},
},
});
}
const onHandlers = new WeakMap<HTMLElement, EventListenerOrEventListenerObject>();
// Extend HTMLElement to include the oncommand property for TypeScript
declare global {
interface HTMLElement {
oncommand: EventListenerOrEventListenerObject | null;
/**
* Gets or sets the element controlled by the interest invoker.
*/
interestForElement: Element | null;
}
interface HTMLButtonElement {
/**
* Gets or sets the command string for the button.
*/
command: string;
/**
* Gets or sets the element controlled by the button.
*/
commandForElement: Element | null;
/**
* Gets or sets the element controlled by the interest invoker.
*/
interestForElement: Element | null;
}
interface HTMLInputElement {
/**
* Gets or sets the command string for the input.
*/
command: string;
/**
* Gets or sets the element controlled by the input.
*/
commandForElement: Element | null;
/**
* Gets or sets the element controlled by the interest invoker.
*/
interestForElement: Element | null;
}
interface HTMLTextAreaElement {
/**
* Gets or sets the command string for the textarea.
*/
command: string;
/**
* Gets or sets the element controlled by the textarea.
*/
commandForElement: Element | null;
/**
* Gets or sets the element controlled by the interest invoker.
*/
interestForElement: Element | null;
}
interface HTMLAnchorElement {
/**
* Gets or sets the element controlled by the interest invoker.
*/
interestForElement: Element | null;
}
interface HTMLAreaElement {
/**
* Gets or sets the element controlled by the interest invoker.
*/
interestForElement: Element | null;
}
}
/**
* Extends `HTMLElement.prototype` to include `oncommand` event handler.
*/
Object.defineProperties(HTMLElement.prototype, {
oncommand: {
enumerable: true,
configurable: true,
get(): EventListenerOrEventListenerObject | null {
oncommandObserver.takeRecords(); // Ensure up-to-date state
return onHandlers.get(this) || null;
},
set(handler: EventListenerOrEventListenerObject | null) {
const existing = onHandlers.get(this) || null;
if (existing) {
this.removeEventListener("command", existing);
}
if (typeof handler === "object" || typeof handler === "function") {
if (handler !== null) {
onHandlers.set(this, handler);
} else {
onHandlers.delete(this);
}
} else {
onHandlers.delete(this);
}
if (typeof handler == "function") {
this.addEventListener("command", handler);
}
},
},
});
/**
* Applies `oncommand` attribute values as event handlers.
* @param els An iterable of elements to apply handlers to.
*/
function applyOnCommandHandler(els: Iterable<Element>) {
for (const el of els) {
if (typeof Element !== "undefined" && !(el instanceof Element)) continue; // Skip if not an Element
const oncommandAttr = el.getAttribute("oncommand");
if (oncommandAttr !== null && (el as any).oncommand !== null) { // Only set if not already set by JS
try {
// eslint-disable-next-line no-new-func
(el as any).oncommand = new Function("event", oncommandAttr) as EventListener;
} catch (e) {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.error(`Invokers Polyfill: Error parsing oncommand attribute for element:`, el, e);
}
}
}
}
}
/** MutationObserver to watch for new `oncommand` attributes or elements. */
const oncommandObserver = new MutationObserver((records) => {
for (const record of records) {
const { target } = record;
if (record.type === "childList") {
if (typeof Element !== "undefined" && target instanceof Element) {
applyOnCommandHandler(Array.from(target.querySelectorAll("[oncommand]")));
} else if (target && typeof (target as any).querySelectorAll === "function") {
applyOnCommandHandler(Array.from((target as Element).querySelectorAll("[oncommand]")));
}
} else { // attributeChanged
if (target instanceof HTMLElement && target.hasAttribute("oncommand")) {
applyOnCommandHandler([target]);
} else if (target instanceof HTMLElement) { // attribute removed
target.oncommand = null; // Clear handler if attribute removed
}
}
}
});
/**
* Handles the activation of an invoker button, dispatches `CommandEvent`,
* and performs default actions for built-in commands.
* @param event The click event that triggered the activation.
*/
function handleInvokerActivation(event: MouseEvent | KeyboardEvent) {
// Respect defaultPrevented (e.g., if another handler already stopped it)
if (event.defaultPrevented) return;
// Only process click events (or key events, if extending)
if (event.type !== "click") return;
// Check for deprecated attributes and warn
const oldInvoker = (event.target as HTMLElement).closest(
"button[invoketarget], button[invokeaction], input[invoketarget], input[invokeaction], textarea[invoketarget], textarea[invokeaction]",
);
if (oldInvoker) {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn(
"Invokers Polyfill: Elements with `invoketarget` or `invokeaction` are deprecated and should be renamed to use `commandfor` and `command` respectively",
);
}
}
// Find the actual invoker element (button, input, or textarea)
const source = (event.target as HTMLElement).closest<HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement>("button[command], input[command], textarea[command]");
if (!source) return; // Not an invoker element
// Validate element type and attributes for forms
if (source.form && source.localName === "button" && source.getAttribute("type") !== "button") {
event.preventDefault(); // Prevent form submission
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.error( // Use console.error as this is an invalid setup
"Invokers Polyfill: Button with `command` is a form participant. " +
"It should explicitly set `type=button` in order for `command` to work. " +
"To act as a Submit/Reset button, it must not have command or commandfor attributes.",
source
);
}
return;
}
// For native commands, both command and commandfor must be present
// For custom commands (--prefix), commandfor is optional
const isCustomCommand = source.command.startsWith('--');
if (!isCustomCommand && !source.hasAttribute("commandfor")) {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.error( // Use console.error as this is an invalid setup
`Invokers Polyfill: Element with native command must also have a commandfor attribute to function.`,
source
);
}
return;
}
// Validate command value based on spec
// Note: source.command getter already normalizes built-in values and validates `--` prefix
if (source.command === "") {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn(
`Invokers Polyfill: "${source.getAttribute("command")}" is not a valid command value for element:`,
source,
`Custom commands must begin with --`
);
}
return;
}
let invokee = source.commandForElement;
if (!invokee) {
// For custom commands without commandfor, dispatch to document.body
if (isCustomCommand) {
invokee = document.body;
} else {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn("Invokers Polyfill: commandfor target not found for invoker:", source);
}
return;
}
}
// Helper function to check if a command is built-in
function isBuiltInCommand(command: string): boolean {
const normalized = command.toLowerCase();
return normalized === 'show-modal' ||
normalized === 'close' ||
normalized === 'request-close' ||
normalized === 'toggle-popover' ||
normalized === 'hide-popover' ||
normalized === 'show-popover' ||
normalized === 'toggle-openable' ||
normalized === 'close-openable' ||
normalized === 'open-openable' ||
normalized === 'toggle' ||
normalized === 'open' ||
normalized === 'play-pause' ||
normalized === 'play' ||
normalized === 'pause' ||
normalized === 'toggle-muted' ||
normalized === 'show-picker' ||
normalized === 'step-up' ||
normalized === 'step-down' ||
normalized === 'copy-text' ||
normalized === 'share' ||
normalized === 'toggle-fullscreen' ||
normalized === 'request-fullscreen' ||
normalized === 'exit-fullscreen';
}
// Helper function to split commands on commas, respecting escaped commas
function splitCommands(commandString: string): string[] {
const commands: string[] = [];
let currentCommand = '';
let i = 0;
let braceDepth = 0;
while (i < commandString.length) {
const char = commandString[i];
if (char === '\\' && i + 1 < commandString.length && commandString[i + 1] === ',') {
// Escaped comma - include the comma in the current command
currentCommand += ',';
i += 2; // Skip the backslash and comma
} else if (char === '{') {
braceDepth++;
currentCommand += char;
i++;
} else if (char === '}') {
braceDepth--;
currentCommand += char;
i++;
} else if (char === ',' && braceDepth === 0) {
// Unescaped comma outside braces - split here
if (currentCommand.trim().length > 0) {
commands.push(currentCommand.trim());
}
currentCommand = '';
i++;
} else {
currentCommand += char;
i++;
}
}
// Add the last command if any
if (currentCommand.trim().length > 0) {
commands.push(currentCommand.trim());
}
return commands;
}
// Split comma-separated commands and dispatch events for each
// Use the raw attribute value to preserve original formatting
const rawCommandValue = source.getAttribute('command') || '';
const commands = splitCommands(rawCommandValue);
for (const command of commands) {
// Validate each individual command
if (!command.startsWith('--') && !isBuiltInCommand(command)) {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn(
`Invokers Polyfill: "${command}" is not a valid command value. Custom commands must begin with --`
);
}
continue; // Skip invalid commands but continue with others
}
// 1. Dispatch the CommandEvent for each command
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.log('Polyfill dispatching CommandEvent for command:', command);
}
const commandEvent = new CommandEventPolyfill("command", {
command: command,
source,
cancelable: true,
bubbles: true, // Should bubble to be caught by document listeners
composed: true, // Allow crossing shadow boundaries
});
invokee.dispatchEvent(commandEvent);
// If the event was prevented, stop default behavior for this command
if (commandEvent.defaultPrevented) continue;
// 2. Perform default actions for built-in commands
const normalizedCommand = commandEvent.command.toLowerCase(); // Use the normalized command from the event
// Handle popover commands
if (invokee.matches('[popover]')) {
const isPopoverOpen = invokee.matches(":popover-open");
if (normalizedCommand === "toggle-popover") {
(invokee as HTMLElement & { showPopover?: (options?: any) => void; hidePopover?: (options?: any) => void; })[isPopoverOpen ? 'hidePopover' : 'showPopover']?.({ source });
} else if (normalizedCommand === "hide-popover" && isPopoverOpen) {
(invokee as HTMLElement & { hidePopover: Function }).hidePopover();
} else if (normalizedCommand === "show-popover" && !isPopoverOpen) {
(invokee as HTMLElement & { showPopover?: (options?: any) => void }).showPopover?.({ source });
}
}
// Handle dialog commands
if (invokee.localName === "dialog") {
const isDialogOpen = invokee.hasAttribute("open");
if (normalizedCommand === "show-modal" && !isDialogOpen) {
(invokee as HTMLDialogElement).showModal();
} else if (normalizedCommand === "close" && isDialogOpen) {
(invokee as HTMLDialogElement).close();
}
}
// Handle details commands
if (invokee.localName === "details") {
const isOpen = (invokee as HTMLDetailsElement).open;
if (normalizedCommand === "toggle") {
(invokee as HTMLDetailsElement).open = !isOpen;
} else if (normalizedCommand === "open" && !isOpen) {
(invokee as HTMLDetailsElement).open = true;
} else if (normalizedCommand === "close" && isOpen) {
(invokee as HTMLDetailsElement).open = false;
}
}
// Handle openable elements (elements with toggleOpenable method)
if (normalizedCommand.includes("openable") && typeof (invokee as any).toggleOpenable === "function") {
if (normalizedCommand === "toggle-openable") {
(invokee as any).toggleOpenable();
} else if (normalizedCommand === "open-openable") {
(invokee as any).openOpenable?.();
} else if (normalizedCommand === "close-openable") {
(invokee as any).closeOpenable?.();
}
}
// Handle picker commands for select and input elements
if ((invokee.localName === "select" || invokee.localName === "input") && normalizedCommand === "show-picker") {
try {
if (typeof (invokee as any).showPicker === "function") {
// Check if we're in a secure context and have user activation
if (document.hasFocus() && source.ownerDocument.hasFocus()) {
(invokee as any).showPicker();
}
}
} catch (e) {
// showPicker can throw for various security reasons, fail silently
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn("Invokers: showPicker failed:", e);
}
}
}
// Handle media element commands
if (invokee.localName === "video" || invokee.localName === "audio") {
const media = invokee as HTMLMediaElement;
if (normalizedCommand === "play-pause") {
if (media.paused) {
media.play().catch(() => {
// Autoplay policy might prevent play, fail silently
});
} else {
media.pause();
}
} else if (normalizedCommand === "play" && media.paused) {
media.play().catch(() => {
// Autoplay policy might prevent play, fail silently
});
} else if (normalizedCommand === "pause" && !media.paused) {
media.pause();
} else if (normalizedCommand === "toggle-muted") {
media.muted = !media.muted;
}
}
// Handle fullscreen commands
if (normalizedCommand.includes("fullscreen")) {
if (normalizedCommand === "toggle-fullscreen") {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else {
invokee.requestFullscreen().catch(() => {});
}
} else if (normalizedCommand === "request-fullscreen" && !document.fullscreenElement) {
invokee.requestFullscreen().catch(() => {});
} else if (normalizedCommand === "exit-fullscreen" && document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
// Handle clipboard commands
if (normalizedCommand === "copy-text" && typeof navigator.clipboard !== "undefined") {
// For form elements, prefer value attribute over text content
const textToCopy = (invokee as HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement).value || invokee.textContent || "";
navigator.clipboard.writeText(textToCopy).catch(() => {
// Clipboard access might be denied, fail silently
});
}
// Handle share commands
if (normalizedCommand === "share" && typeof navigator.share !== "undefined") {
const content = invokee.textContent || "";
// Check if content looks like a URL
const urlPattern = /^https?:\/\/[^\s]+$/i;
const shareData: any = {};
if (urlPattern.test(content.trim())) {
shareData.url = content.trim();
} else {
shareData.text = content;
}
navigator.share(shareData).catch(() => {
// Share might not be supported or user cancelled, fail silently
});
}
// Handle number input commands
if (invokee.localName === "input" && (invokee as HTMLInputElement).type === "number") {
const input = invokee as HTMLInputElement;
if (normalizedCommand === "step-up") {
input.stepUp();
} else if (normalizedCommand === "step-down") {
input.stepDown();
}
}
}
}
/**
* Sets up global click listener for invoker buttons.
* @param target The DOM node to attach the listener to (e.g., `document` or a ShadowRoot).
*/
function setupInvokeListeners(target: Node) {
// Add debug logging to track listener attachment
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.log('Invokers: Setting up click listeners on:', target);
}
target.addEventListener("click", handleInvokerActivation as EventListener, true); // Use capturing to catch first
// Mark that the listener was successfully attached
if (typeof window !== 'undefined') {
(window as any).__invokerClickListenerVerified = true;
}
// Verify the listener was added by checking if we can see it (development debug)
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.log('Invokers: Click listener attached successfully');
}
}
/**
* Observes newly attached Shadow DOM roots to apply listeners and `oncommand` handlers.
* @param ElementClass The base class (e.g., `HTMLElement`) to observe shadow roots for.
* @param callback The function to call with the new ShadowRoot.
*/
function observeShadowRoots(ElementClass: typeof HTMLElement, callback: (shadowRoot: ShadowRoot) => void) {
// Patch attachShadow
const attachShadow = ElementClass.prototype.attachShadow;
ElementClass.prototype.attachShadow = function (init: ShadowRootInit) {
const shadow = attachShadow.call(this, init);
callback(shadow);
return shadow;
};
// Patch attachInternals for declarative custom elements
const attachInternals = ElementClass.prototype.attachInternals;
ElementClass.prototype.attachInternals = function (this: Element) { // Use 'this' context
const internals = attachInternals.call(this);
if (internals.shadowRoot) callback(internals.shadowRoot);
return internals;
};
}
function applyToTarget(target: any) {
// Add debug logging to track polyfill application
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.log('Invokers: Applying polyfill to target:', target);
console.log('Invokers: Native support check:', isSupported());
}
// INVOKERS LIBRARY: Always apply the polyfill for consistent behavior
// Unlike typical polyfills, we want consistent behavior across all browsers
// and don't want to rely on varying native implementations
const hasNativeSupport = isSupported();
if (hasNativeSupport) {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.log('Invokers: Native support detected, but applying polyfill for consistency');
}
}
// Ensure the polyfill is only applied once
if ((target as any).CommandEvent === CommandEventPolyfill) {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.log('Invokers: Polyfill already applied, skipping');
}
return; // Already applied
}
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.log('Invokers: Proceeding with polyfill application');
}
// Hijack native 'invoke' and 'command' events if they exist,
// to prevent conflicts and ensure our polyfilled event is the one processed.
// This is a crucial step if browsers partially implement or change behavior.
target.document.addEventListener(
"invoke",
(e: Event) => {
if (e.type === "invoke" && e.isTrusted) {
e.stopImmediatePropagation();
e.preventDefault();
}
},
true,
);
target.document.addEventListener(
"command",
(e: Event) => {
// Only prevent if it's a native/trusted command event
if (e.type === "command" && e.isTrusted && !e.defaultPrevented && e.eventPhase === Event.AT_TARGET) {
// Check if a CommandEvent, not just a generic custom event type "command"
// This is a tricky part: we want to block native CommandEvents if they exist,
// but allow our own polyfilled CommandEvents to proceed.
// A heuristic could be `e instanceof CommandEventPolyfill` but that might not work
// if `globalThis.CommandEvent` is already the native one.
// For now, if native is supported, our polyfill will largely step aside anyway.
// This listener is primarily for older `invoke` event issues and preventing double-firing
// in environments where CommandEvent is partially or inconsistently implemented.
// For a full polyfill, we'd ensure `isSupported()` is false before applying.
// Given `invokers` wants a consistent environment, we always apply.
// For now, let's assume if it's a trusted 'command' event from another source, we block.
// The `invokers` library will then dispatch its own `CommandEventPolyfill` which won't be blocked here.
e.stopImmediatePropagation();
e.preventDefault();
}
},
true,
);
// Apply the `command` and `commandfor` properties to HTMLButtonElement
applyInvokerMixin(target.HTMLButtonElement);
// Also apply to input and textarea elements for command-on support
applyInvokerMixin(target.HTMLInputElement);
applyInvokerMixin(target.HTMLTextAreaElement);
// Observe newly attached Shadow DOM roots
observeShadowRoots(target.HTMLElement, (shadow) => {
setupInvokeListeners(shadow);
oncommandObserver.observe(shadow, { subtree: true, childList: true, attributeFilter: ["oncommand"] });
applyOnCommandHandler(Array.from(shadow.querySelectorAll("[oncommand]")));
});
// Set up listeners for the main document
setupInvokeListeners(target.document);
// Fallback: ensure listeners are attached even if there were issues above
if (typeof window !== 'undefined') {
// Use setTimeout to ensure this runs after all initialization
setTimeout(() => {
// Double-check that click listeners are actually attached
if (!(window as any).__invokerClickListenerVerified) {
if ((window as any).Invoker?.debug) {
console.warn('Invokers: Click listener verification failed, adding fallback listener');
}
// Add a fallback click listener to ensure functionality
document.addEventListener("click", (event: MouseEvent) => {
if (event.defaultPrevented) return;
if (event.type !== "click") return;
const source = (event.target as HTMLElement).closest('button[command], input[command], textarea[command]');
if (!source) return;
const command = source.getAttribute('command');
if (!command) return;
let target = null;
const commandfor = source.getAttribute('commandfor');
if (commandfor) {
target = document.getElementById(commandfor) || document.querySelector(commandfor);
}
if (!target && command.startsWith('--')) {
target = document.body;
}
if (!target) return;
const commandEvent = new (window as any).CommandEvent("command", {
command: command,
source: source,
cancelable: true,
bubbles: true,
composed: true,
});
target.dispatchEvent(commandEvent);
}, true);
(window as any).__invokerClickListenerVerified = true;
}
}, 0);
}
// Initial scan for `oncommand` attributes
oncommandObserver.observe(target.document, {
subtree: true,
childList: true,
attributeFilter: ["oncommand"],
});
applyOnCommandHandler(Array.from(target.document.querySelectorAll("[oncommand]")));
// Expose the polyfilled CommandEvent globally if not already defined
if (typeof (target as any)['CommandEvent'] === 'undefined') {
Object.defineProperty(target, "CommandEvent", {
value: CommandEventPolyfill,
configurable: true,
writable: true,
enumerable: false,
});
} else {
if (typeof window !== 'undefined' && (window as any).Invoker?.debug) {
console.warn("Invokers Polyfill: `CommandEvent` already exists. The polyfill's CommandEvent will not overwrite it.");
}
}
// Expose InvokeEvent globally (for deprecation warnings)
if (typeof (target as any)['InvokeEvent'] === 'undefined') {
Object.defineProperty(target, "InvokeEvent", {
value: InvokeEventPolyfill,
configurable: true,
writable: true,
enumerable: false,
});
}
}
/**
* Applies the Invoker Buttons polyfill to the current environment.
* This should be called once to enable the `command`/`commandfor` attributes and `CommandEvent`.
*/
export function apply() {
const target = (typeof global !== "undefined" && (global as any).window) || (typeof window !== "undefined" ? window : globalThis);
applyToTarget(target);
// Also apply to global.window immediately if it exists and is different (for test environments)
const globalWindow = (typeof global !== "undefined" && (global as any).window);
if (globalWindow && globalWindow !== target) {
applyToTarget(globalWindow);
}
// Use setTimeout as additional safety for test environments that set global.window after import
setTimeout(() => {
const delayedGlobalWindow = (typeof global !== "undefined" && (global as any).window);
if (delayedGlobalWindow && delayedGlobalWindow !== target && delayedGlobalWindow !== globalWindow) {
applyToTarget(delayedGlobalWindow);
}
}, 0);
}
// Automatically apply the polyfill when this module is imported.
// This ensures that the global CommandEvent and attribute setters are ready
// before InvokerManager tries to use them.
apply();