Skip to content

Commit 43a7d7e

Browse files
committed
Refactor form handling and utility functions for improved clarity and functionality
1 parent 3061d5c commit 43a7d7e

File tree

4 files changed

+922
-830
lines changed

4 files changed

+922
-830
lines changed

packages/formspree-ajax/src/form.ts

Lines changed: 13 additions & 308 deletions
Original file line numberDiff line numberDiff line change
@@ -5,249 +5,21 @@ import {
55
appendExtraData,
66
type Client,
77
type FieldValues,
8-
type SubmissionError,
9-
type SubmissionSuccess,
108
} from '@formspree/core';
9+
import type { FormConfig, FormContext, FormHandle } from './types';
1110
import {
12-
DataAttributes,
13-
type FormConfig,
14-
type FormContext,
15-
type FormElement,
16-
type FormHandle,
17-
type MessageType,
18-
} from './types';
19-
20-
const getFormElement = (elementOrSelector: FormElement): HTMLFormElement => {
21-
if (typeof elementOrSelector === 'string') {
22-
const element = document.querySelector(elementOrSelector);
23-
if (!element) {
24-
throw new Error(`Element "${elementOrSelector}" not found`);
25-
}
26-
if (!(element instanceof HTMLFormElement)) {
27-
throw new Error(`Element "${elementOrSelector}" is not a form element`);
28-
}
29-
return element;
30-
}
31-
return elementOrSelector;
32-
};
33-
34-
/**
35-
* Internal marker attribute. Set on elements whose text content was injected
36-
* by the library (i.e. the element was originally empty). Used to know when
37-
* it is safe to clear the text on hide — user-provided content is never cleared.
38-
*/
39-
const SERVER_CONTENT_ATTR = 'data-fs-server-content';
40-
41-
/**
42-
* Shows an element by setting `data-fs-active`.
43-
* If the element is empty and a message is provided, the message is injected
44-
* and the element is marked with `data-fs-server-content` so it can be cleared later.
45-
*/
46-
const showElement = (element: HTMLElement, message?: string): void => {
47-
if (message && element.textContent?.trim() === '') {
48-
element.textContent = message;
49-
element.setAttribute(SERVER_CONTENT_ATTR, '');
50-
}
51-
element.setAttribute(DataAttributes.ACTIVE, '');
52-
};
53-
54-
/**
55-
* Hides an element by removing `data-fs-active`.
56-
* If the text was injected by the library, it is cleared.
57-
*/
58-
const hideElement = (element: HTMLElement): void => {
59-
element.removeAttribute(DataAttributes.ACTIVE);
60-
if (element.hasAttribute(SERVER_CONTENT_ATTR)) {
61-
element.textContent = '';
62-
element.removeAttribute(SERVER_CONTENT_ATTR);
63-
}
64-
};
65-
66-
const defaultOnSuccess = <T extends FieldValues>(
67-
context: FormContext<T>,
68-
_result: SubmissionSuccess
69-
): void => {
70-
const { form } = context;
71-
const replacement = document.createElement('div');
72-
replacement.textContent = 'Thank you!';
73-
form.parentNode?.replaceChild(replacement, form);
74-
};
75-
76-
/**
77-
* Default implementation to enable submit buttons.
78-
* Restores original button text and re-enables the button.
79-
*/
80-
const defaultEnable = <T extends FieldValues>(
81-
context: FormContext<T>
82-
): void => {
83-
const buttons = context.form.querySelectorAll<HTMLButtonElement>(
84-
`[type='submit']:disabled, [${DataAttributes.SUBMIT_BTN}]:disabled`
85-
);
86-
buttons.forEach((button) => {
87-
const originalText = button.dataset.fsOriginalText;
88-
if (originalText) {
89-
button.textContent = originalText;
90-
delete button.dataset.fsOriginalText;
91-
}
92-
button.disabled = false;
93-
});
94-
};
95-
96-
/**
97-
* Default implementation to disable submit buttons.
98-
* Saves original button text, shows a loading spinner, and disables the button.
99-
*/
100-
const defaultDisable = <T extends FieldValues>(
101-
context: FormContext<T>
102-
): void => {
103-
const buttons = context.form.querySelectorAll<HTMLButtonElement>(
104-
`[type='submit']:enabled, [${DataAttributes.SUBMIT_BTN}]:enabled`
105-
);
106-
buttons.forEach((button) => {
107-
button.dataset.fsOriginalText = button.textContent ?? '';
108-
button.disabled = true;
109-
});
110-
};
111-
112-
/**
113-
* Default implementation to render field-level validation errors in the DOM.
114-
*
115-
* - Finds elements with `data-fs-error="fieldName"` and shows/hides them.
116-
* If the element is empty, injects the first error message from the API.
117-
* If the element has user-provided content, shows it as-is.
118-
* - Sets `aria-invalid="true"` on `data-fs-field` inputs whose `name` matches an errored field.
119-
*/
120-
const defaultRenderFieldErrors = <T extends FieldValues>(
121-
context: FormContext<T>,
122-
error: SubmissionError<T> | null
123-
): void => {
124-
const { form } = context;
125-
126-
// Handle field error elements (data-fs-error="fieldName")
127-
const errorElements = form.querySelectorAll<HTMLElement>(
128-
`[${DataAttributes.ERROR}]`
129-
);
130-
131-
errorElements.forEach((element) => {
132-
const fieldName = element.dataset.fsError;
133-
134-
// Skip form-level error elements (no value) — handled by renderFormMessage
135-
if (!fieldName) return;
136-
137-
if (!error) {
138-
hideElement(element);
139-
return;
140-
}
141-
142-
const fieldErrors = error.getFieldErrors(fieldName as keyof T);
143-
144-
if (fieldErrors.length === 0) {
145-
hideElement(element);
146-
return;
147-
}
148-
149-
showElement(element, fieldErrors[0].message);
150-
});
151-
152-
// Handle field elements (aria-invalid)
153-
const fieldElements = form.querySelectorAll<HTMLElement>(
154-
`[${DataAttributes.FIELD}]`
155-
);
156-
157-
fieldElements.forEach((element) => {
158-
const fieldName = element.getAttribute('name');
159-
160-
if (!fieldName) {
161-
element.removeAttribute('aria-invalid');
162-
return;
163-
}
164-
165-
if (!error) {
166-
element.removeAttribute('aria-invalid');
167-
return;
168-
}
169-
170-
const fieldErrors = error.getFieldErrors(fieldName as keyof T);
171-
172-
if (fieldErrors.length === 0) {
173-
element.removeAttribute('aria-invalid');
174-
return;
175-
}
176-
177-
element.setAttribute('aria-invalid', 'true');
178-
});
179-
};
180-
181-
/**
182-
* Finds an element by attribute, searching inside the form first,
183-
* then in the form's parent container.
184-
*/
185-
const findElement = (
186-
form: HTMLFormElement,
187-
selector: string
188-
): HTMLElement | null => {
189-
return (
190-
form.querySelector<HTMLElement>(selector) ??
191-
form.parentElement?.querySelector<HTMLElement>(selector) ??
192-
null
193-
);
194-
};
195-
196-
/**
197-
* Finds the success element (`data-fs-success`) for a form.
198-
*/
199-
const findSuccessElement = (form: HTMLFormElement): HTMLElement | null => {
200-
return findElement(form, `[${DataAttributes.SUCCESS}]`);
201-
};
202-
203-
/**
204-
* Finds the form-level error element (`data-fs-error` without a value) for a form.
205-
*/
206-
const findFormErrorElement = (form: HTMLFormElement): HTMLElement | null => {
207-
return findElement(form, `[${DataAttributes.ERROR}=""]`);
208-
};
209-
210-
/**
211-
* Default implementation to render form-level messages in the DOM.
212-
*
213-
* - On success: shows the `data-fs-success` element.
214-
* - On error: shows the `data-fs-error` element (without a value).
215-
* - If the element is empty, injects the provided message.
216-
* - If the element has user-provided content, shows it as-is.
217-
*/
218-
const defaultRenderFormMessage = <T extends FieldValues>(
219-
context: FormContext<T>,
220-
type: MessageType | null,
221-
message: string | null
222-
): void => {
223-
const successEl = findSuccessElement(context.form);
224-
const formErrorEl = findFormErrorElement(context.form);
225-
226-
if (type === null) {
227-
if (successEl) hideElement(successEl);
228-
if (formErrorEl) hideElement(formErrorEl);
229-
return;
230-
}
231-
232-
if (type === 'success') {
233-
if (formErrorEl) hideElement(formErrorEl);
234-
if (successEl) showElement(successEl, message ?? undefined);
235-
} else {
236-
if (successEl) hideElement(successEl);
237-
if (formErrorEl) showElement(formErrorEl, message ?? undefined);
238-
}
239-
};
240-
241-
/**
242-
* Builds a human-readable error message from a SubmissionError.
243-
* Only includes form-level errors; field errors are displayed inline via `data-fs-error` elements.
244-
*/
245-
const buildErrorMessage = <T extends FieldValues>(
246-
error: SubmissionError<T>
247-
): string => {
248-
const formErrors = error.getFormErrors().map((e) => e.message);
249-
return formErrors.join(', ') || 'There was an error submitting the form.';
250-
};
11+
buildErrorMessage,
12+
DEFAULT_ENDPOINT,
13+
defaultDisable,
14+
defaultEnable,
15+
defaultOnSuccess,
16+
defaultRenderFieldErrors,
17+
defaultRenderFormMessage,
18+
findSuccessElement,
19+
getFormElement,
20+
injectDefaultStyles,
21+
log,
22+
} from './utils';
25123

25224
const handleSubmit = async <T extends FieldValues>(
25325
context: FormContext<T>
@@ -337,73 +109,6 @@ const handleSubmit = async <T extends FieldValues>(
337109
}
338110
};
339111

340-
const log = (message: string, data?: unknown): void => {
341-
console.log(`[formspree-ajax] ${message}`, data ?? '');
342-
};
343-
344-
const DEFAULT_ENDPOINT = 'https://formspree.io';
345-
346-
/**
347-
* Flag to track whether default styles have been injected.
348-
*/
349-
let stylesInjected = false;
350-
351-
/**
352-
* Injects default Formspree styles into the document head.
353-
* Only runs once per page load.
354-
*/
355-
const injectDefaultStyles = (): void => {
356-
if (stylesInjected || typeof document === 'undefined') {
357-
return;
358-
}
359-
360-
const styleElement = document.createElement('style');
361-
styleElement.setAttribute('data-formspree-styles', '');
362-
styleElement.textContent = `
363-
[${DataAttributes.ERROR}],
364-
[${DataAttributes.SUCCESS}] {
365-
display: none;
366-
}
367-
368-
[${DataAttributes.ERROR}][${DataAttributes.ACTIVE}] {
369-
display: block;
370-
color: #dc3545;
371-
}
372-
373-
[${DataAttributes.ERROR}=""][${DataAttributes.ACTIVE}] {
374-
padding: 12px;
375-
border-radius: 8px;
376-
margin-bottom: 20px;
377-
font-size: 14px;
378-
background: #f8d7da;
379-
border: 1px solid #f5c6cb;
380-
}
381-
382-
[${DataAttributes.ERROR}]:not([${DataAttributes.ERROR}=""])[${DataAttributes.ACTIVE}] {
383-
font-size: 12px;
384-
margin-top: 4px;
385-
}
386-
387-
[${DataAttributes.SUCCESS}][${DataAttributes.ACTIVE}] {
388-
display: block;
389-
padding: 12px;
390-
border-radius: 8px;
391-
margin-bottom: 20px;
392-
font-size: 14px;
393-
background: #d4edda;
394-
color: #155724;
395-
border: 1px solid #c3e6cb;
396-
}
397-
398-
[${DataAttributes.FIELD}][aria-invalid="true"] {
399-
border-color: #dc3545;
400-
}
401-
`;
402-
403-
document.head.appendChild(styleElement);
404-
stylesInjected = true;
405-
};
406-
407112
export const initForm = <T extends FieldValues = FieldValues>(
408113
config: FormConfig<T>
409114
): FormHandle => {

0 commit comments

Comments
 (0)