Skip to content

Commit 159b1b2

Browse files
Copilottanepiper
andcommitted
TypeScript conversion Phase 2: Form utility modules
Converted 7 additional form modules from .mjs to .mts: - src/lib/form/enrichment.mts - Field enrichment with proper types - src/lib/form/extract.mts - Field value extraction with comprehensive typing - src/lib/form/aria.mts - ARIA attribute management - src/lib/form/dirty.mts - Dirty state tracking - src/lib/form/touch.mts - Touch state handling - src/lib/form/init.mts - Form initialization and reset All functions properly typed with: - No 'any' types used - Proper generics and constraints - Type guards where needed - Strong typing for DOM elements - Full type safety for store operations Progress: 11/29 files converted (38%) Co-authored-by: tanepiper <376930+tanepiper@users.noreply.github.com>
1 parent d3b6e4b commit 159b1b2

File tree

6 files changed

+455
-0
lines changed

6 files changed

+455
-0
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { FormElement } from '../shared/fields.mjs';
2+
3+
/**
4+
* Recursively find the parent element that contains a radio group
5+
*/
6+
function getRadioGroupParent(el?: HTMLElement): HTMLElement | undefined {
7+
if (!el || !el.parentElement) {
8+
return undefined;
9+
}
10+
11+
const parent = el.parentElement;
12+
const isGroupContainer = parent.querySelectorAll(':scope input[type=radio]').length > 1;
13+
const hasStoppingAttribute = parent.dataset?.beakerGroup || parent.dataset?.formulaForm;
14+
15+
if (isGroupContainer && !hasStoppingAttribute) {
16+
return parent;
17+
}
18+
19+
return getRadioGroupParent(parent);
20+
}
21+
22+
/**
23+
* Sets the ARIA role for the given element based on its input type
24+
*/
25+
export function setAriaRole(el: FormElement, elements: FormElement[]): void {
26+
if (el.hasAttribute('aria-role')) {
27+
return;
28+
}
29+
30+
const setRole = (role: string) => el.setAttribute('aria-role', role);
31+
32+
if (el.type === 'radio') {
33+
if (elements.length < 2) {
34+
el?.parentElement?.setAttribute('aria-role', 'radiogroup');
35+
} else {
36+
const radioGroup = getRadioGroupParent(el);
37+
if (radioGroup) radioGroup.setAttribute('aria-role', 'radiogroup');
38+
}
39+
setRole('radio');
40+
} else {
41+
setRole(
42+
(function () {
43+
switch (el.type) {
44+
case 'select-one':
45+
case 'select-multiple':
46+
case 'checkbox':
47+
return el.type;
48+
case 'file':
49+
return 'file-upload';
50+
case 'textarea':
51+
return 'textbox';
52+
default:
53+
return `input-${el.type}`;
54+
}
55+
})()
56+
);
57+
}
58+
}
59+
60+
/**
61+
* Sets ARIA states based on the attributes of the form element
62+
*/
63+
export function setAriaStates(el: FormElement): void {
64+
if (el.hasAttribute('required')) {
65+
el.setAttribute('aria-required', 'true');
66+
}
67+
}
68+
69+
/**
70+
* Updates the ARIA checked state for the given element
71+
*/
72+
export function setAriaValue(element: FormElement, elGroup: FormElement[]): void {
73+
if (element.type === 'radio') {
74+
elGroup.forEach((el) => el.removeAttribute('aria-checked'));
75+
element.setAttribute('aria-checked', element.checked ? 'true' : 'false');
76+
} else if (element.type === 'checkbox') {
77+
element.setAttribute('aria-checked', element.checked ? 'true' : 'false');
78+
}
79+
}
80+
81+
/**
82+
* Sets the ARIA role for the container element
83+
*/
84+
export function setAriaContainer(container: HTMLElement, isGroup: boolean): void {
85+
if (!container.hasAttribute('aria-role')) {
86+
container.setAttribute('aria-role', isGroup ? 'row' : 'form');
87+
}
88+
}
89+
90+
/**
91+
* Adds the ARIA button role to all buttons in the container
92+
*/
93+
export function setAriaButtons(container: HTMLElement): void {
94+
const nonAriaButtons = Array.from(container.querySelectorAll('button:not([aria-role])'));
95+
nonAriaButtons.forEach((el) => el.setAttribute('aria-role', 'button'));
96+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { FormElement } from '../shared/fields.mjs';
2+
import type { FormulaStores } from '../shared/types.mjs';
3+
4+
/**
5+
* Check if two arrays have the same elements, regardless of the order
6+
*/
7+
const matchingArrays = (array1: unknown[], array2: unknown[]): boolean =>
8+
array1.length === array2.length && array1.every((e) => array2.includes(e));
9+
10+
/**
11+
* Creates a handler to set the dirty state for a group of elements
12+
*/
13+
export function createDirtyHandler(
14+
name: string,
15+
elements: FormElement[],
16+
stores: FormulaStores
17+
): () => void {
18+
const elementHandlers = new Map<FormElement, () => void>();
19+
const initialValues = new Map<string, unknown>();
20+
let subscriptionUnsub: (() => void) | undefined;
21+
22+
const setDirtyAndStopListening = () => {
23+
for (const [el, handler] of elementHandlers) {
24+
el.setAttribute('data-formula-dirty', 'true');
25+
el.removeEventListener('blur', handler);
26+
}
27+
elementHandlers.clear();
28+
if (subscriptionUnsub) {
29+
subscriptionUnsub();
30+
subscriptionUnsub = undefined;
31+
}
32+
};
33+
34+
// Set initial dirty state and initial value
35+
stores.dirty.set({ ...stores.dirty.get(), [name]: false });
36+
// Capture initial value once and unsubscribe immediately
37+
const unsubInitial = stores.formValues.subscribe((v) => initialValues.set(name, v[name]));
38+
unsubInitial();
39+
subscriptionUnsub = undefined; // No ongoing subscription needed for initial values
40+
41+
function createElementHandler(groupName: string): () => void {
42+
return () => {
43+
const startValue = initialValues.get(groupName);
44+
const currentValues = stores.formValues.get();
45+
46+
const isDirty = Array.isArray(currentValues[groupName])
47+
? !matchingArrays(currentValues[groupName] as unknown[], startValue as unknown[])
48+
: currentValues[groupName] !== startValue;
49+
50+
if (isDirty) {
51+
stores.dirty.set({ ...stores.dirty.get(), [groupName]: true });
52+
setDirtyAndStopListening();
53+
}
54+
};
55+
}
56+
57+
for (const el of elements) {
58+
const handler = createElementHandler(name);
59+
el.addEventListener('blur', handler);
60+
elementHandlers.set(el, handler);
61+
}
62+
63+
return setDirtyAndStopListening;
64+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { EnrichFields } from '../shared/types.mjs';
2+
3+
interface EnrichmentOptions {
4+
enrich?: EnrichFields;
5+
}
6+
7+
/**
8+
* Creates an enrichment object for the named group
9+
*/
10+
export function createEnrichField(
11+
name: string,
12+
options?: EnrichmentOptions
13+
): (value: unknown) => Record<string, unknown> {
14+
return (value: unknown) =>
15+
Object.entries(options?.enrich?.[name] ?? {}).reduce((a, [key, fn]) => {
16+
a[key] = fn(value);
17+
return a;
18+
}, {} as Record<string, unknown>);
19+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { createValidationChecker } from './errors.mjs';
2+
import { setAriaValue } from './aria.mjs';
3+
import type { FormElement } from '../shared/fields.mjs';
4+
import type { FormulaStores } from '../shared/types.mjs';
5+
import type { FieldValidity, ValidationMessages, ValidationRules, EnrichFields } from '../shared/types.mjs';
6+
7+
interface ExtractOptions {
8+
defaultValues?: Record<string, unknown>;
9+
messages?: ValidationMessages;
10+
validators?: ValidationRules;
11+
enrich?: EnrichFields;
12+
}
13+
14+
interface FieldExtractResult extends FieldValidity {
15+
name: string;
16+
value: unknown;
17+
}
18+
19+
/**
20+
* Get selected option values from a multi-select element
21+
*/
22+
function getMultiSelectOptionValues(collection: HTMLCollectionOf<HTMLOptionElement>): string[] {
23+
const selectedValues: string[] = [];
24+
for (let i = 0; i < collection.length; i++) {
25+
if (collection[i].selected) {
26+
selectedValues.push(collection[i].value);
27+
}
28+
}
29+
return selectedValues;
30+
}
31+
32+
/**
33+
* Sets the value of the element
34+
*/
35+
function setElementValue(
36+
element: FormElement,
37+
value: unknown,
38+
isMultiValue: boolean,
39+
elementGroup: FormElement[]
40+
): void {
41+
if (isMultiValue) {
42+
const valueArray = Array.isArray(value) ? value : [];
43+
elementGroup.forEach((el, i) => {
44+
if (el.type === 'checkbox') {
45+
el.checked = valueArray.includes(el.value);
46+
} else {
47+
el.value = String(valueArray[i] ?? '');
48+
}
49+
});
50+
} else {
51+
if (element instanceof HTMLSelectElement) {
52+
const valueArray = Array.isArray(value) ? value : [value];
53+
[...element.options].forEach((el) => {
54+
el.selected = valueArray.includes(el.value);
55+
});
56+
} else if (element.type === 'checkbox') {
57+
element.checked = Boolean(value);
58+
} else if (element.type === 'radio') {
59+
elementGroup.forEach((el) => (el.checked = value === el.value));
60+
} else if (element.type === 'file') {
61+
element.files = value instanceof FileList ? value : null;
62+
} else {
63+
element.value = String(value ?? '');
64+
}
65+
}
66+
}
67+
68+
/**
69+
* Get the value or values from an element
70+
*/
71+
function getElementValues(
72+
element: FormElement,
73+
isMultiValue: boolean,
74+
elementGroup: FormElement[]
75+
): unknown {
76+
let elValue: unknown;
77+
78+
if (element instanceof HTMLSelectElement) {
79+
elValue = element.multiple ? getMultiSelectOptionValues(element.options) : element.value || null;
80+
} else {
81+
switch (element.type) {
82+
case 'number':
83+
case 'range':
84+
elValue = isMultiValue
85+
? elementGroup.map((v) => parseFloat(v.value)).filter((v) => !isNaN(v))
86+
: (() => {
87+
const val = parseFloat(element.value);
88+
return !isNaN(val) ? val : null;
89+
})();
90+
break;
91+
case 'checkbox':
92+
elValue = isMultiValue ? elementGroup.filter((e) => e.checked).map((e) => e.value) : element.checked;
93+
break;
94+
case 'radio':
95+
const foundElement = elementGroup.find((el) => el.checked);
96+
elValue = foundElement ? foundElement.value : null;
97+
break;
98+
case 'file':
99+
elValue = element.files;
100+
break;
101+
default:
102+
elValue = isMultiValue ? elementGroup.map((v) => v.value) : element.value || null;
103+
}
104+
}
105+
106+
return elValue;
107+
}
108+
109+
/**
110+
* Create a data handler for any type of input field
111+
*/
112+
export function createFieldExtract(
113+
name: string,
114+
elementGroup: FormElement[],
115+
stores: FormulaStores,
116+
options?: ExtractOptions
117+
): (element: FormElement, isInit: boolean, isReset: boolean) => FieldExtractResult {
118+
const values = stores.formValues.get();
119+
const validator = createValidationChecker(name, elementGroup, values, options);
120+
121+
let isMultiValue = false;
122+
if (elementGroup[0].type !== 'radio') {
123+
isMultiValue = !elementGroup[0].multiple && elementGroup.length > 1;
124+
}
125+
126+
/**
127+
* Function called on every element update, can also be called at initial value
128+
*/
129+
return (element: FormElement, isInit: boolean, isReset: boolean): FieldExtractResult => {
130+
let value: unknown;
131+
if (isInit && options?.defaultValues?.[name] !== undefined) {
132+
value = isMultiValue ? options?.defaultValues?.[name] || [] : options?.defaultValues?.[name] || '';
133+
} else {
134+
value = stores.formValues.get()[name] ?? (isMultiValue ? [] : '');
135+
}
136+
137+
if (!isReset) {
138+
const elValue = getElementValues(element, isMultiValue, elementGroup);
139+
// Handle empty value fields that return null
140+
if (elValue === null && !isInit && (Array.isArray(value) ? value.length > 0 : value !== '')) {
141+
value = '';
142+
}
143+
if (elValue !== null) {
144+
value = isInit && isMultiValue && Array.isArray(elValue) && elValue.length === 0 ? value : elValue;
145+
}
146+
}
147+
148+
if (isInit || isReset) {
149+
setElementValue(element, value, isMultiValue, elementGroup);
150+
}
151+
152+
setAriaValue(element, elementGroup);
153+
154+
let fieldName = name;
155+
if (element.dataset?.formulaName) {
156+
fieldName = element.dataset.formulaName;
157+
}
158+
159+
return {
160+
name: fieldName,
161+
value,
162+
...validator(element, value),
163+
};
164+
};
165+
}

0 commit comments

Comments
 (0)