Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions dom/event-delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,31 @@ export class EventDelegator {
}

// Auto-intercept forms (progressive complexity).
// Button name IS the action. Resolution order:
// 1. submitter.name (button name = action)
// 2. form.name (form name = action)
// 3. "" (server defaults to Submit())
// Action resolution order:
// 1. lvt-form:action attribute (explicit routing)
// 2. submitter.name (button name = action)
// 3. form.name (form name = action)
// 4. "submit" (server defaults to Submit())
//
// Note: lvt-action hidden field is a server-side progressive
// enhancement fallback (no-JS POST). The client does not read it;
// the server extracts it from form data directly.
if (!action && eventType === "submit" && element instanceof HTMLFormElement) {
if (!element.hasAttribute("lvt-form:no-intercept")) {
// Check for explicit routing attribute first.
// Empty string ("") falls through to submitter/form name resolution.
const explicitAction = element.getAttribute("lvt-form:action");
const submitter = (e as SubmitEvent).submitter;
if (submitter instanceof HTMLButtonElement && submitter.name) {
action = submitter.name;
} else if (element.name) {
action = element.name;
if (explicitAction) {
action = explicitAction;
} else {
Comment on lines +154 to 171
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions a legacy lvt-action hidden field in the action resolution order, but this client code doesn't read any such field (and it isn't referenced elsewhere in the repo). Either implement that resolution step here or adjust the PR description to avoid advertising unsupported behavior.

Copilot uses AI. Check for mistakes.
action = "submit";
if (submitter instanceof HTMLButtonElement && submitter.name) {
action = submitter.name;
} else if (element.name) {
action = element.name;
} else {
action = "submit";
}
Comment on lines +154 to +178
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action resolution comment says the final fallback is an empty string (""), but the implementation sets action = "submit". Update the comment (or the fallback value) so docs match behavior; the PR description also states the default should be "submit".

Copilot uses AI. Check for mistakes.
}
actionElement = element;

Expand Down Expand Up @@ -233,13 +245,18 @@ export class EventDelegator {
).map((el) => (el as HTMLInputElement).name)
);

// Determine which form field key is the action routing key
// (the submitter button's name, or "action" field)
// Exclude the submitter button's name from form data.
// The submitter's name is used as the action routing key in the
// button-name path — including it in data would be redundant.
// When lvt-form:action overrides routing, the button name is still
// excluded to avoid noisy payloads (the button is a UI control,
// not domain data). Button value and data-* attrs are collected below.
// "action" is NOT excluded — it's a normal data field.
const submitterForData = (targetElement as any).__lvtSubmitter as HTMLButtonElement | undefined;
const actionFieldName = submitterForData?.name || "action";
const actionFieldName = submitterForData?.name;

formData.forEach((value, key) => {
if (key === actionFieldName || key === "action") return;
if (actionFieldName && key === actionFieldName) return;
if (checkboxNames.has(key)) {
message.data[key] = true;
this.logger.debug("Converted checkbox", key, "to true");
Expand Down
6 changes: 3 additions & 3 deletions dom/link-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface LinkInterceptorContext {
/**
* Intercepts <a> clicks within the LiveTemplate wrapper for SPA navigation.
* Same-origin links are fetched via fetch() and the wrapper content is replaced.
* External links, target="_blank", download, and lvt-no-intercept are skipped.
* External links, target="_blank", download, and lvt-nav:no-intercept are skipped.
*/
export class LinkInterceptor {
private popstateListener: (() => void) | null = null;
Expand Down Expand Up @@ -58,8 +58,8 @@ export class LinkInterceptor {
if (link.target && link.target !== "_self") return true;
// Download links
if (link.hasAttribute("download")) return true;
// Opt-out (lvt-no-intercept is the generic opt-out for both forms and links)
if (link.hasAttribute("lvt-no-intercept")) return true;
// Opt-out attribute for link interception
if (link.hasAttribute("lvt-nav:no-intercept")) return true;
// Hash-only links (scroll anchors)
if (link.pathname === window.location.pathname && link.hash) return true;
// mailto/tel/javascript
Expand Down
4 changes: 2 additions & 2 deletions state/change-auto-wirer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class ChangeAutoWirer {
const parentForm = el.closest("form");
if (parentForm) {
if (parentForm.hasAttribute("lvt-change")) continue;
if (parentForm.hasAttribute("lvt-no-intercept")) continue;
if (parentForm.hasAttribute("lvt-form:no-intercept")) continue;
}

if (el instanceof HTMLInputElement) {
Expand Down Expand Up @@ -107,7 +107,7 @@ export class ChangeAutoWirer {
const parentForm = el.closest("form");
if (parentForm) {
if (parentForm.hasAttribute("lvt-change")) continue;
if (parentForm.hasAttribute("lvt-no-intercept")) continue;
if (parentForm.hasAttribute("lvt-form:no-intercept")) continue;
}

const name = el.getAttribute("name");
Expand Down
8 changes: 4 additions & 4 deletions tests/change-auto-wirer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,9 @@ describe("ChangeAutoWirer", () => {
expect(sendSpy).not.toHaveBeenCalled();
});

it("skips elements inside form with lvt-no-intercept", () => {
it("skips elements inside form with lvt-form:no-intercept", () => {
const form = document.createElement("form");
form.setAttribute("lvt-no-intercept", "");
form.setAttribute("lvt-form:no-intercept", "");
const input = document.createElement("input");
input.setAttribute("name", "Title");
form.appendChild(input);
Expand Down Expand Up @@ -625,9 +625,9 @@ describe("ChangeAutoWirer", () => {
expect(sendSpy).not.toHaveBeenCalled();
});

it("skips select inside form with lvt-no-intercept", () => {
it("skips select inside form with lvt-form:no-intercept", () => {
const form = document.createElement("form");
form.setAttribute("lvt-no-intercept", "");
form.setAttribute("lvt-form:no-intercept", "");
const select = document.createElement("select");
select.setAttribute("name", "sort_by");
form.appendChild(select);
Expand Down
81 changes: 81 additions & 0 deletions tests/event-delegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,87 @@ describe("EventDelegator", () => {
);
});

it("lvt-form:action attribute takes priority over button name", () => {
const wrapper = document.createElement("div");
wrapper.setAttribute("data-lvt-id", "wrapper-form-action");
wrapper.innerHTML = `
<form id="action-form" lvt-form:action="checkout">
<input type="text" name="item" value="widget" />
<button type="submit" id="submit" name="save">Save</button>
</form>
`;
document.body.appendChild(wrapper);

const form = document.getElementById("action-form") as HTMLFormElement;
const submitButton = document.getElementById("submit") as HTMLButtonElement;

const context = createContext(wrapper);
const delegator = new EventDelegator(
context,
createLogger({ scope: "EventDelegatorTest", level: "silent" })
);
delegator.setupEventDelegation();

const submitEvent = new Event("submit", {
bubbles: true,
cancelable: true,
}) as SubmitEvent & { submitter?: HTMLButtonElement };
submitEvent.submitter = submitButton;

form.dispatchEvent(submitEvent);

expect(context.send).toHaveBeenCalledTimes(1);
// lvt-form:action="checkout" takes priority over button name="save"
expect(context.send).toHaveBeenCalledWith(
expect.objectContaining({
action: "checkout",
data: expect.objectContaining({ item: "widget" }),
})
);
});

it("action field in form data is preserved as normal data", () => {
const wrapper = document.createElement("div");
wrapper.setAttribute("data-lvt-id", "wrapper-action-data");
wrapper.innerHTML = `
<form id="workflow-form" lvt-form:action="processStep">
<input type="hidden" name="action" value="approve" />
<input type="text" name="reason" value="looks good" />
<button type="submit" id="submit">Submit</button>
</form>
`;
document.body.appendChild(wrapper);

const form = document.getElementById("workflow-form") as HTMLFormElement;
const submitButton = document.getElementById("submit") as HTMLButtonElement;

const context = createContext(wrapper);
const delegator = new EventDelegator(
context,
createLogger({ scope: "EventDelegatorTest", level: "silent" })
);
delegator.setupEventDelegation();

const submitEvent = new Event("submit", {
bubbles: true,
cancelable: true,
}) as SubmitEvent & { submitter?: HTMLButtonElement };
submitEvent.submitter = submitButton;

form.dispatchEvent(submitEvent);

expect(context.send).toHaveBeenCalledTimes(1);
expect(context.send).toHaveBeenCalledWith(
expect.objectContaining({
action: "processStep",
data: expect.objectContaining({
action: "approve", // "action" is NOT reserved — it flows through as data
reason: "looks good",
}),
})
);
});

describe("focus trap", () => {
it("sets up keydown listener for focus trap", () => {
const wrapper = document.createElement("div");
Expand Down
Loading