Skip to content

Commit c0049d9

Browse files
Copilottanepiper
andcommitted
TypeScript conversion Phase 4: Complex modules and root files
Converted remaining core source files from .mjs to .mts: - src/lib/form/form.mts - Main form logic with proper typing - src/lib/group/group.mts - Beaker group management with type safety - src/lib/webcomponent/index.mts - Web component with TypeScript - src/lib/webcomponent/lib.mts - Event name mapping utility - index.mts - Root entry point with proper exports All complex modules now properly typed with: - No 'any' types used - Generic constraints for flexibility - Strong typing for DOM interactions - Proper interface definitions exported Progress: 17/29 files converted (59%) Remaining: 12 test .spec.mts files + config updates Co-authored-by: tanepiper <376930+tanepiper@users.noreply.github.com>
1 parent 56506d5 commit c0049d9

File tree

5 files changed

+501
-0
lines changed

5 files changed

+501
-0
lines changed

packages/formula/index.mts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createForm } from './src/lib/form/form.mjs';
2+
import { createGroup } from './src/lib/group/group.mjs';
3+
4+
import type { FormulaStores, BeakerStores, FormulaOptions, BeakerOptions } from './src/lib/shared/types.mjs';
5+
import type { Formula } from './src/lib/form/form.mjs';
6+
import type { Beaker } from './src/lib/group/group.mjs';
7+
8+
export { FormulaWebComponent } from './src/lib/webcomponent/index.mjs';
9+
10+
/**
11+
* A global map of stores for elements with an `id` property and the `use` directive,
12+
* if no ID is used the store is not added
13+
*/
14+
export const formulaStores = new Map<string, FormulaStores>();
15+
16+
/**
17+
* A global map of stores for beaker groups with an `id` property and the `use` directive,
18+
* if no ID is used the store is not added
19+
*/
20+
export const beakerStores = new Map<string, BeakerStores>();
21+
22+
/**
23+
* The `formula` function returns a form object that can be bound to any HTML
24+
* element that contains form inputs. Once bound you can get the current values
25+
*/
26+
export function formula(options?: FormulaOptions): Formula & FormulaStores {
27+
return createForm(options || {}, formulaStores, undefined, {}) as Formula & FormulaStores;
28+
}
29+
30+
/**
31+
* The beaker function returns an instance of a group of elements and their stores, it also provides methods
32+
* to set the group value store
33+
*/
34+
export function beaker(options?: BeakerOptions): Beaker & BeakerStores {
35+
return createGroup(options || {}, beakerStores) as Beaker & BeakerStores;
36+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { getFormFields, getGroupFields } from '../shared/fields.mjs';
2+
import { createHandler, createSubmitHandler } from './event.mjs';
3+
import { createReset } from './init.mjs';
4+
import { createTouchHandlers } from './touch.mjs';
5+
import { createDirtyHandler } from './dirty.mjs';
6+
7+
import { createFormStores } from '../shared/stores.mjs';
8+
import { setAriaButtons, setAriaContainer, setAriaRole, setAriaStates } from './aria.mjs';
9+
10+
import type { FormulaStores, FormulaOptions } from '../shared/types.mjs';
11+
12+
export interface Formula {
13+
init: (node: HTMLElement) => FormulaForm;
14+
updateForm: (updatedOpts?: FormulaOptions) => void;
15+
destroyForm: () => void;
16+
resetForm: () => void;
17+
stores: FormulaStores;
18+
}
19+
20+
export interface FormulaForm {
21+
root: HTMLElement;
22+
elements: Array<[string, HTMLElement[]]>;
23+
destroy: () => void;
24+
}
25+
26+
export function createForm(
27+
options: FormulaOptions,
28+
globalStore: Map<string, FormulaStores> | undefined,
29+
groupName: string | undefined,
30+
initialData: Record<string, unknown>
31+
): Formula {
32+
const eventHandlers = new Map<HTMLElement, Array<() => void>>();
33+
const hiddenGroups = new Map<string, HTMLElement[]>();
34+
const touchHandlers = new Set<() => void>();
35+
const dirtyHandlers = new Set<() => void>();
36+
37+
const stores = createFormStores(options, initialData);
38+
const isGroup = typeof groupName !== 'undefined';
39+
const initialOptions = options;
40+
let submitHandler: ((e: Event) => void) | undefined = undefined;
41+
let unsub = () => {};
42+
let innerReset = () => {};
43+
44+
let groupedMap: Array<[string, HTMLElement[]]> = [];
45+
46+
function bindElements(node: HTMLElement, innerOpt: FormulaOptions = {}) {
47+
if (!innerOpt?.preChanges) {
48+
innerOpt.preChanges = () => {
49+
node?.parentElement?.dispatchEvent(new CustomEvent('form:preChanges', { detail: undefined }));
50+
};
51+
}
52+
if (!innerOpt?.postChanges) {
53+
innerOpt.postChanges = (values: Record<string, unknown>) => {
54+
node?.parentElement?.dispatchEvent(new CustomEvent('form:postChanges', { detail: values }));
55+
};
56+
}
57+
58+
const formElements = isGroup ? getGroupFields(node) : getFormFields(node);
59+
60+
node.setAttribute(`data-formula-${isGroup ? 'row' : 'form'}`, 'true');
61+
setAriaContainer(node, isGroup);
62+
setAriaButtons(node);
63+
64+
groupedMap = [
65+
...formElements.reduce((entryMap, e) => {
66+
const formulaName = e.dataset.formulaName;
67+
const name = formulaName || e.getAttribute('name') || '';
68+
return entryMap.set(name, [...(entryMap.get(name) || []), e]);
69+
}, new Map<string, HTMLElement[]>()),
70+
];
71+
72+
innerReset = createReset(node, groupedMap, stores, innerOpt);
73+
74+
groupedMap.forEach(([name, elements]) => {
75+
if ((elements[0] as HTMLInputElement).type === 'hidden') {
76+
hiddenGroups.set(name, elements);
77+
return;
78+
}
79+
80+
touchHandlers.add(createTouchHandlers(name, elements, stores));
81+
dirtyHandlers.add(createDirtyHandler(name, elements, stores));
82+
83+
elements.forEach((el) => {
84+
if (isGroup && groupName) {
85+
el.setAttribute('data-in-group', groupName);
86+
}
87+
setAriaRole(el, elements);
88+
setAriaStates(el);
89+
90+
const customBindings = el.dataset.formulaBind;
91+
if (customBindings) {
92+
const cleanups: Array<() => void> = [];
93+
customBindings.split('|').forEach((event) => {
94+
cleanups.push(createHandler(name, event, el, elements, stores, innerOpt, hiddenGroups));
95+
});
96+
eventHandlers.set(el, cleanups);
97+
} else if (el instanceof HTMLSelectElement) {
98+
eventHandlers.set(el, [createHandler(name, 'change', el, elements, stores, innerOpt, hiddenGroups)]);
99+
} else {
100+
const changeEventTypes = ['radio', 'checkbox', 'file', 'range', 'color', 'date', 'time', 'week', 'number'];
101+
const cleanups: Array<() => void> = [];
102+
103+
if (changeEventTypes.includes((el as HTMLInputElement).type)) {
104+
cleanups.push(createHandler(name, 'change', el, elements, stores, innerOpt, hiddenGroups));
105+
}
106+
107+
if ((el as HTMLInputElement).type !== 'hidden') {
108+
cleanups.push(createHandler(name, 'keyup', el, elements, stores, innerOpt, hiddenGroups));
109+
}
110+
111+
if (cleanups.length > 0) {
112+
eventHandlers.set(el, cleanups);
113+
}
114+
}
115+
});
116+
});
117+
118+
if (node.id && globalStore) globalStore.set(node.id, stores);
119+
120+
if (node instanceof HTMLFormElement) {
121+
submitHandler = createSubmitHandler(stores, node);
122+
node.addEventListener('submit', submitHandler);
123+
}
124+
stores.formReady.set(true);
125+
}
126+
127+
let currentNode: HTMLElement;
128+
129+
function cleanupSubscriptions() {
130+
unsub && unsub();
131+
[...eventHandlers].forEach(([el, fns]) => {
132+
(el as HTMLInputElement).setCustomValidity?.('');
133+
fns.forEach(fn => fn());
134+
});
135+
[...touchHandlers, ...dirtyHandlers].forEach((fn) => fn());
136+
[eventHandlers, touchHandlers, dirtyHandlers].forEach((h) => h.clear());
137+
if (submitHandler && currentNode instanceof HTMLFormElement) {
138+
currentNode.removeEventListener('submit', submitHandler);
139+
}
140+
}
141+
142+
return {
143+
init: (node: HTMLElement): FormulaForm => {
144+
currentNode = node;
145+
bindElements(node, options);
146+
return {
147+
root: node,
148+
elements: groupedMap,
149+
destroy: () => {
150+
stores.formReady.set(false);
151+
cleanupSubscriptions();
152+
currentNode.id && globalStore && globalStore.delete(currentNode.id);
153+
},
154+
};
155+
},
156+
updateForm: (updatedOpts?: FormulaOptions) => {
157+
stores.formReady.set(false);
158+
cleanupSubscriptions();
159+
bindElements(currentNode, updatedOpts || initialOptions);
160+
},
161+
destroyForm: () => {
162+
stores.formReady.set(false);
163+
cleanupSubscriptions();
164+
currentNode.id && globalStore && globalStore.delete(currentNode.id);
165+
},
166+
resetForm: () => {
167+
innerReset();
168+
[...touchHandlers, ...dirtyHandlers].forEach((fn) => fn());
169+
groupedMap.forEach(([name, elements]) => {
170+
touchHandlers.add(createTouchHandlers(name, elements, stores));
171+
dirtyHandlers.add(createDirtyHandler(name, elements, stores));
172+
});
173+
},
174+
stores,
175+
...stores,
176+
};
177+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { createGroupStores } from '../shared/stores.mjs';
2+
import { createForm } from '../form/form.mjs';
3+
4+
import type { BeakerStores, BeakerOptions, FormulaStores, FormulaOptions } from '../shared/types.mjs';
5+
import type { Formula } from '../form/form.mjs';
6+
7+
export interface Beaker {
8+
group: (node: HTMLElement) => { destroy: () => void };
9+
update: (options: FormulaOptions) => void;
10+
destroy: () => void;
11+
forms: Map<HTMLElement, Formula>;
12+
stores: BeakerStores;
13+
init: (items: Array<Record<string, unknown>>) => void;
14+
add: (item: Record<string, unknown>) => void;
15+
set: (index: number, item: Record<string, unknown>) => void;
16+
delete: (index: number) => void;
17+
clear: () => void;
18+
}
19+
20+
let groupCounter = 0;
21+
22+
export function createGroup(
23+
options: BeakerOptions,
24+
beakerStores: Map<string, BeakerStores>
25+
): Beaker {
26+
const groupStores = createGroupStores(options);
27+
let groupName: string;
28+
let globalObserver: MutationObserver;
29+
30+
const { defaultValues = [], ...formulaOptions } = options || {};
31+
32+
const formulaInstances = new Map<HTMLElement, Formula>();
33+
const formInstances = new Map<HTMLElement, { root: HTMLElement; elements: Array<[string, HTMLElement[]]>; destroy: () => void }>();
34+
const subscriptions = new Set<() => void>();
35+
36+
function destroyGroup() {
37+
formInstances.forEach((instance) => instance.destroy());
38+
subscriptions.forEach((sub) => sub());
39+
formInstances.clear();
40+
formulaInstances.clear();
41+
subscriptions.clear();
42+
}
43+
44+
function cleanupStores(rows: Element[]) {
45+
for (const key of Object.keys(groupStores)) {
46+
if (['formValues', 'initialValues', 'submitValues'].includes(key)) continue;
47+
const state = (groupStores as Record<string, { get: () => unknown; set: (value: unknown) => void }>)[key].get();
48+
(groupStores as Record<string, { get: () => unknown; set: (value: unknown) => void }>)[key].set(
49+
Array.isArray(state) ? state.slice(0, rows.length) : state
50+
);
51+
}
52+
}
53+
54+
function setupSubscriptions(form: Formula, index: number) {
55+
const formStores = Object.entries(form.stores) as Array<[string, { subscribe: (fn: (value: unknown) => void) => () => void }]>;
56+
for (const [key, store] of formStores) {
57+
let initial = true;
58+
const unsub = store.subscribe((value) => {
59+
if (initial && key === 'formValues') {
60+
initial = false;
61+
return;
62+
}
63+
initial = false;
64+
const state = (groupStores as Record<string, { get: () => unknown; set: (value: unknown) => void }>)[key].get();
65+
if (Array.isArray(state)) {
66+
const newState = [...state];
67+
newState.splice(index, 1, value);
68+
(groupStores as Record<string, { get: () => unknown; set: (value: unknown) => void }>)[key].set(newState);
69+
} else {
70+
(groupStores as Record<string, { get: () => unknown; set: (value: unknown) => void }>)[key].set(state);
71+
}
72+
});
73+
subscriptions.add(unsub);
74+
}
75+
}
76+
77+
function groupHasChanged(rows: Element[]) {
78+
groupStores.formReady.set(false);
79+
const currentVals = groupStores.formValues.get();
80+
destroyGroup();
81+
cleanupStores(rows);
82+
for (let i = 0; i < rows.length; i++) {
83+
const row = rows[i] as HTMLElement;
84+
row.setAttribute('data-beaker-index', `${i}`);
85+
const form = createForm(
86+
{
87+
...formulaOptions,
88+
defaultValues: defaultValues?.[i] || {},
89+
},
90+
undefined,
91+
groupName,
92+
currentVals[i] || {}
93+
);
94+
const instance = form.init(row);
95+
formulaInstances.set(row, form);
96+
formInstances.set(row, instance);
97+
setupSubscriptions(form, i);
98+
}
99+
groupStores.formReady.set(true);
100+
}
101+
102+
function setupGroupContainer(node: HTMLElement) {
103+
globalObserver = new MutationObserver(() => {
104+
const rows = node.querySelectorAll(':scope > *');
105+
groupHasChanged(Array.from(rows));
106+
});
107+
108+
globalObserver.observe(node, { childList: true });
109+
const rows = node.querySelectorAll(':scope > *');
110+
groupHasChanged(Array.from(rows));
111+
}
112+
113+
return {
114+
group: (node: HTMLElement) => {
115+
if (node.id) {
116+
groupName = node.id;
117+
beakerStores.set(groupName, groupStores);
118+
} else {
119+
groupName = `beaker-group-${groupCounter++}`;
120+
node.id = groupName;
121+
}
122+
123+
node.setAttribute('data-beaker-group', 'true');
124+
if (!node.hasAttribute('aria-role')) {
125+
node.setAttribute('aria-role', 'group');
126+
}
127+
setupGroupContainer(node);
128+
129+
return {
130+
destroy: () => {
131+
if (groupName) {
132+
beakerStores.delete(groupName);
133+
}
134+
destroyGroup();
135+
globalObserver.disconnect();
136+
},
137+
};
138+
},
139+
update: (options: FormulaOptions) => {
140+
formulaInstances.forEach((form) => form.updateForm(options));
141+
},
142+
destroy: () => {
143+
if (groupName) {
144+
beakerStores.delete(groupName);
145+
}
146+
destroyGroup();
147+
globalObserver.disconnect();
148+
},
149+
forms: formulaInstances,
150+
stores: groupStores,
151+
init: (items: Array<Record<string, unknown>>) => groupStores.formValues.set(items),
152+
add: (item: Record<string, unknown>) => groupStores.formValues.set([...groupStores.formValues.get(), item]),
153+
set: (index: number, item: Record<string, unknown>) => {
154+
const newState = [...groupStores.formValues.get()];
155+
newState.splice(index, 1, item);
156+
groupStores.formValues.set(newState);
157+
},
158+
delete: (index: number) =>
159+
Object.keys(groupStores).forEach((key) => {
160+
const state = (groupStores as Record<string, { get: () => unknown; set: (value: unknown) => void }>)[key].get();
161+
if (Array.isArray(state)) {
162+
const newState = [...state];
163+
newState.splice(index, 1);
164+
(groupStores as Record<string, { get: () => unknown; set: (value: unknown) => void }>)[key].set(newState);
165+
}
166+
}),
167+
clear: () => groupStores.formValues.set([]),
168+
...groupStores,
169+
};
170+
}

0 commit comments

Comments
 (0)