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
89 changes: 89 additions & 0 deletions projects/kit/src/lib/utils/dom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,93 @@ describe('disableHandler', () => {
await disableHandler(event, Promise.reject(new Error('boom')));
expect(button.disabled).toBe(false);
});

it('prevents form navigation and disables a native submitter', async () => {
const form = document.createElement('form');
const button = document.createElement('button');
button.type = 'submit';
form.appendChild(button);
const preventDefault = vi.fn();
const event = {
type: 'submit',
target: form,
currentTarget: form,
submitter: button,
preventDefault,
} as unknown as SubmitEvent;
let disabledDuringWork = false;

await disableHandler(
event,
Promise.resolve().then(() => (disabledDuringWork = button.disabled)),
);

expect(preventDefault).toHaveBeenCalledOnce();
expect(disabledDuringWork).toBe(true);
expect(button.disabled).toBe(false);
});

it('disables an external ion-button associated with the submitted form', async () => {
const form = document.createElement('form');
const proxy = document.createElement('button');
proxy.type = 'submit';
proxy.style.display = 'none';
form.appendChild(proxy);
const ionButton = document.createElement('ion-button') as HTMLElement & {
disabled: boolean;
form: HTMLFormElement;
};
ionButton.setAttribute('type', 'submit');
ionButton.disabled = false;
ionButton.form = form;
document.body.append(form, ionButton);
const event = {
type: 'submit',
target: form,
currentTarget: form,
submitter: proxy,
preventDefault: vi.fn(),
} as unknown as SubmitEvent;
let disabledDuringWork = false;

await disableHandler(
event,
Promise.resolve().then(() => (disabledDuringWork = ionButton.disabled)),
);

expect(disabledDuringWork).toBe(true);
expect(ionButton.disabled).toBe(false);
form.remove();
ionButton.remove();
});

it('restores the original disabled state of every submitter for the form', async () => {
const form = document.createElement('form');
const first = document.createElement('ion-button') as HTMLElement & {
disabled: boolean;
form: HTMLFormElement;
};
const second = document.createElement('ion-button') as typeof first;
[first, second].forEach((button) => {
button.setAttribute('type', 'submit');
button.form = form;
});
first.disabled = false;
second.disabled = true;
document.body.append(form, first, second);
const event = {
type: 'submit',
target: form,
submitter: document.createElement('button'),
preventDefault: vi.fn(),
} as unknown as SubmitEvent;

await disableHandler(event, Promise.resolve());

expect(first.disabled).toBe(false);
expect(second.disabled).toBe(true);
form.remove();
first.remove();
second.remove();
});
});
76 changes: 61 additions & 15 deletions projects/kit/src/lib/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,70 @@
type DisableableElement = HTMLElement & { disabled: boolean };

const isDisableable = (element: EventTarget | null): element is DisableableElement =>
element instanceof HTMLElement && 'disabled' in element;

const getIonicSubmitButtons = (form: HTMLFormElement): DisableableElement[] =>
Array.from(document.querySelectorAll<HTMLElement>('ion-button[type="submit"]')).filter((button): button is DisableableElement => {
if (!isDisableable(button)) return false;

const associatedForm = (button as HTMLElement & { form?: HTMLFormElement | string }).form;
return (
associatedForm === form ||
(typeof associatedForm === 'string' && associatedForm === form.id) ||
(form.id !== '' && button.getAttribute('form') === form.id) ||
button.closest('form') === form
);
});

const getDisableTargets = (event: Event): DisableableElement[] => {
const submitter = 'submitter' in event ? (event as SubmitEvent).submitter : null;
const form = event.target instanceof HTMLFormElement ? event.target : null;
const targets: DisableableElement[] = [];

if (event.type === 'submit' && form) {
targets.push(...getIonicSubmitButtons(form));
}

if (isDisableable(submitter)) {
const root = submitter.getRootNode();
const target = root instanceof ShadowRoot ? root.host : submitter;
if (isDisableable(target)) targets.push(target);
}

if (targets.length === 0 && isDisableable(event.currentTarget)) targets.push(event.currentTarget);
if (targets.length === 0 && isDisableable(event.target)) targets.push(event.target);
return [...new Set(targets)];
};

/**
* Disable the button that triggered an event while an async operation runs, re-enabling it after.
* Disable the controls that triggered an event while an async operation runs.
*
* @remarks
* Prevents the common double-submit / double-tap bug: the `event.target` button is disabled, the
* work is awaited, and the button is re-enabled — even if the work rejects (the rejection is
* swallowed here so the button always recovers; handle errors inside `work` if you need to react).
* For a click event, the clicked control is disabled. For a submit event, the submitter is
* disabled and the browser's default form navigation is prevented. Ionic's external
* `ion-button[form]` uses a hidden native submitter, so buttons associated with the submitted form
* are resolved through their `form` property and disabled instead. Rejections are swallowed so
* controls always recover; handle errors inside `work` when the caller needs to react.
*
* @param event - The DOM event whose `target` is the button to disable (e.g. a click event).
* @param work - The async operation to run while the button is disabled.
* @returns A Promise that resolves once the work has settled and the button has been re-enabled.
* @param event - The click or submit event that triggered the operation.
* @param work - The async operation to run while the controls are disabled.
* @returns A Promise that resolves once the work has settled and the controls have been restored.
* @example
* ```ts
* async submit(event: Event): Promise<void> {
* await disableHandler(event, this.save());
* }
* ```html
* <form #formRef (submit)="helper.disableHandler($event, save())"></form>
* <ion-button type="submit" [form]="formRef">Save</ion-button>
* ```
*/
export const disableHandler = async (event: Event, work: Promise<void | boolean>): Promise<void> => {
const target = event.target as HTMLButtonElement;
target.disabled = true;
await work.catch((): undefined => undefined);
target.disabled = false;
if (event.type === 'submit') event.preventDefault();

const targets = getDisableTargets(event);
const disabledStates = targets.map((target) => target.disabled);
targets.forEach((target) => (target.disabled = true));

try {
await work.catch((): undefined => undefined);
} finally {
targets.forEach((target, index) => (target.disabled = disabledStates[index]));
}
};