ts-util-core 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +411 -0
  2. package/dist/core/ajax.d.ts +30 -0
  3. package/dist/core/ajax.d.ts.map +1 -0
  4. package/dist/core/ajax.js +110 -0
  5. package/dist/core/ajax.js.map +1 -0
  6. package/dist/core/event-emitter.d.ts +29 -0
  7. package/dist/core/event-emitter.d.ts.map +1 -0
  8. package/dist/core/event-emitter.js +67 -0
  9. package/dist/core/event-emitter.js.map +1 -0
  10. package/dist/core/message.d.ts +28 -0
  11. package/dist/core/message.d.ts.map +1 -0
  12. package/dist/core/message.js +172 -0
  13. package/dist/core/message.js.map +1 -0
  14. package/dist/core/view.d.ts +49 -0
  15. package/dist/core/view.d.ts.map +1 -0
  16. package/dist/core/view.js +87 -0
  17. package/dist/core/view.js.map +1 -0
  18. package/dist/formatting/formatters.d.ts +9 -0
  19. package/dist/formatting/formatters.d.ts.map +1 -0
  20. package/dist/formatting/formatters.js +109 -0
  21. package/dist/formatting/formatters.js.map +1 -0
  22. package/dist/formatting/registry.d.ts +31 -0
  23. package/dist/formatting/registry.d.ts.map +1 -0
  24. package/dist/formatting/registry.js +49 -0
  25. package/dist/formatting/registry.js.map +1 -0
  26. package/dist/index.d.ts +31 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +104 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/types.d.ts +66 -0
  31. package/dist/types.d.ts.map +1 -0
  32. package/dist/types.js +5 -0
  33. package/dist/types.js.map +1 -0
  34. package/dist/utils/dom.d.ts +38 -0
  35. package/dist/utils/dom.d.ts.map +1 -0
  36. package/dist/utils/dom.js +95 -0
  37. package/dist/utils/dom.js.map +1 -0
  38. package/dist/utils/sprintf.d.ts +16 -0
  39. package/dist/utils/sprintf.d.ts.map +1 -0
  40. package/dist/utils/sprintf.js +116 -0
  41. package/dist/utils/sprintf.js.map +1 -0
  42. package/dist/validation/constraints.d.ts +23 -0
  43. package/dist/validation/constraints.d.ts.map +1 -0
  44. package/dist/validation/constraints.js +131 -0
  45. package/dist/validation/constraints.js.map +1 -0
  46. package/dist/validation/validator.d.ts +45 -0
  47. package/dist/validation/validator.d.ts.map +1 -0
  48. package/dist/validation/validator.js +210 -0
  49. package/dist/validation/validator.js.map +1 -0
  50. package/package.json +26 -0
  51. package/readme.txt +4 -0
  52. package/src/core/ajax.ts +127 -0
  53. package/src/core/event-emitter.ts +84 -0
  54. package/src/core/message.ts +212 -0
  55. package/src/core/view.ts +101 -0
  56. package/src/formatting/formatters.ts +118 -0
  57. package/src/formatting/registry.ts +53 -0
  58. package/src/index.ts +142 -0
  59. package/src/types.ts +85 -0
  60. package/src/utils/dom.ts +105 -0
  61. package/src/utils/sprintf.ts +141 -0
  62. package/src/validation/constraints.ts +168 -0
  63. package/src/validation/validator.ts +276 -0
@@ -0,0 +1,276 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Validator — form validation engine
3
+ //
4
+ // Design patterns used:
5
+ // - Observer Pattern : integrates with VIEW.addBeforeLoad to auto-init
6
+ // - Strategy Pattern : invalid-field handling is injectable via the emitter
7
+ // - Decorator Pattern : constraints are attached to elements by attribute
8
+ // - Registry Pattern : ConstraintHandler lookup by name
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import type { EventEmitter } from '../core/event-emitter.js';
12
+ import type { AppEventMap, ValidationResult, TextareaValidationResult } from '../types.js';
13
+ import { scrollToElement } from '../utils/dom.js';
14
+ import {
15
+ type ConstraintHandler,
16
+ builtInConstraints,
17
+ } from './constraints.js';
18
+
19
+ type InvalidCallback = (labelNames: string[], elements: HTMLElement[]) => void;
20
+ type TextareaTooLongCallback = (
21
+ labelNames: string[],
22
+ maxlengths: number[],
23
+ elements: HTMLElement[],
24
+ ) => void;
25
+
26
+ export class Validator {
27
+ private emitter: EventEmitter<AppEventMap>;
28
+ private constraints: Map<string, ConstraintHandler> = new Map();
29
+ private requiredInvalidCb: InvalidCallback | null = null;
30
+ private textareaTooLongCb: TextareaTooLongCallback | null = null;
31
+
32
+ constructor(emitter: EventEmitter<AppEventMap>) {
33
+ this.emitter = emitter;
34
+
35
+ // Register built-in constraints
36
+ for (const c of builtInConstraints) {
37
+ this.constraints.set(c.name, c);
38
+ }
39
+ }
40
+
41
+ // -- Constraint Registration ------------------------------------------------
42
+
43
+ /**
44
+ * Register a custom constraint handler.
45
+ *
46
+ * Registry Pattern — extend validation without modifying library code.
47
+ */
48
+ addConstraint(handler: ConstraintHandler): void {
49
+ this.constraints.set(handler.name, handler);
50
+ }
51
+
52
+ // -- Callback Setters (Strategy Pattern) ------------------------------------
53
+
54
+ /**
55
+ * Override the default behavior when required-field validation fails.
56
+ *
57
+ * Strategy Pattern — consumers inject their own notification UI.
58
+ */
59
+ setRequiredInvalidCallback(cb: InvalidCallback): void {
60
+ this.requiredInvalidCb = cb;
61
+ }
62
+
63
+ setTextareaTooLongCallback(cb: TextareaTooLongCallback): void {
64
+ this.textareaTooLongCb = cb;
65
+ }
66
+
67
+ // -- Initialization (called by VIEW.addBeforeLoad) --------------------------
68
+
69
+ /**
70
+ * Attach constraint handlers to all matching elements within `context`.
71
+ *
72
+ * This is the **beforeLoad hook** — it is registered with `VIEW.addBeforeLoad`
73
+ * so that dynamically loaded content is also initialized.
74
+ */
75
+ initConstraints(context: HTMLElement): void {
76
+ const elements = context.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
77
+ '[constraint]',
78
+ );
79
+
80
+ for (const el of elements) {
81
+ const tokens = (el.getAttribute('constraint') || '').split(/\s+/);
82
+
83
+ for (const token of tokens) {
84
+ if (!token) continue;
85
+ const handler = this.constraints.get(token);
86
+ handler?.attach(el);
87
+ }
88
+ }
89
+
90
+ // Auto-select text on focus (text inputs and textareas)
91
+ const focusTargets = context.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
92
+ 'input[type="text"], textarea',
93
+ );
94
+ for (const el of focusTargets) {
95
+ el.addEventListener('focus', () => {
96
+ setTimeout(() => el.select(), 1);
97
+ });
98
+ }
99
+
100
+ // Ignore-validation submit buttons
101
+ this.bindIgnoreValidation(context);
102
+ }
103
+
104
+ // -- Form Submit Integration ------------------------------------------------
105
+
106
+ private bindIgnoreValidation(context: HTMLElement): void {
107
+ const buttons = context.querySelectorAll<HTMLInputElement>(
108
+ "input[type='submit'][ignoreValidation='true']",
109
+ );
110
+ for (const btn of buttons) {
111
+ btn.addEventListener('click', () => {
112
+ btn.closest('form')?.classList.add('ignoreValidation');
113
+ });
114
+ }
115
+
116
+ const forms = context.querySelectorAll<HTMLFormElement>('form');
117
+ for (const form of forms) {
118
+ form.addEventListener('submit', (e) => {
119
+ if (!form.classList.contains('ignoreValidation')) {
120
+ if (!this.validate(form)) {
121
+ e.preventDefault();
122
+ }
123
+ }
124
+ form.classList.remove('ignoreValidation');
125
+ });
126
+ }
127
+ }
128
+
129
+ // -- Core Validation Logic --------------------------------------------------
130
+
131
+ /**
132
+ * Validate all required fields and textarea lengths within a container.
133
+ *
134
+ * @returns `true` if all validations pass.
135
+ */
136
+ validate(container: HTMLElement): boolean {
137
+ // Check textarea max-length first
138
+ const tooLong = this.checkTextareaLengths(container);
139
+ if (!tooLong.valid) {
140
+ this.handleTextareaTooLong(tooLong);
141
+ return false;
142
+ }
143
+
144
+ // Check required fields
145
+ const result = this.checkRequired(container);
146
+ if (!result.valid) {
147
+ this.handleRequiredInvalid(result);
148
+ return false;
149
+ }
150
+
151
+ return true;
152
+ }
153
+
154
+ private checkTextareaLengths(container: HTMLElement): TextareaValidationResult {
155
+ const textareas = container.querySelectorAll<HTMLTextAreaElement>('textarea[maxlength]');
156
+ const invalidElements: HTMLElement[] = [];
157
+ const labelNames: string[] = [];
158
+ const maxlengths: number[] = [];
159
+
160
+ for (const ta of textareas) {
161
+ const max = parseInt(ta.getAttribute('maxlength') || '0', 10);
162
+ if (ta.value.length > max) {
163
+ invalidElements.push(ta);
164
+ labelNames.push(ta.getAttribute('labelName') || ta.name || '');
165
+ maxlengths.push(max);
166
+ }
167
+ }
168
+
169
+ return {
170
+ valid: invalidElements.length === 0,
171
+ invalidElements,
172
+ labelNames,
173
+ maxlengths,
174
+ };
175
+ }
176
+
177
+ private checkRequired(container: HTMLElement): ValidationResult {
178
+ const elements = container.querySelectorAll<HTMLElement>(
179
+ "[constraint~='required']",
180
+ );
181
+
182
+ // Group by name (for radio/checkbox groups)
183
+ const groups = new Map<string, HTMLElement[]>();
184
+ let idCounter = 0;
185
+
186
+ for (const el of elements) {
187
+ const input = el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
188
+ let name = input.name || `__unnamed_${idCounter++}`;
189
+
190
+ if (!groups.has(name)) {
191
+ groups.set(name, []);
192
+ }
193
+ groups.get(name)!.push(el);
194
+ }
195
+
196
+ const invalidElements: HTMLElement[] = [];
197
+ const labelNames: string[] = [];
198
+
199
+ for (const [, groupElements] of groups) {
200
+ let notEmpty = false;
201
+
202
+ for (const el of groupElements) {
203
+ const input = el as HTMLInputElement;
204
+
205
+ if (input.type === 'radio' || input.type === 'checkbox') {
206
+ if (input.checked) notEmpty = true;
207
+ } else if (
208
+ (el.getAttribute('constraint') || '').includes('number')
209
+ ) {
210
+ if (input.value !== '0' && input.value !== '') notEmpty = true;
211
+ } else if (input.value && input.value !== '') {
212
+ notEmpty = true;
213
+ }
214
+ }
215
+
216
+ if (!notEmpty) {
217
+ const first = groupElements[0]!;
218
+ invalidElements.push(first);
219
+ labelNames.push(
220
+ first.getAttribute('labelName') ||
221
+ (first as HTMLInputElement).name ||
222
+ '',
223
+ );
224
+ }
225
+ }
226
+
227
+ return {
228
+ valid: invalidElements.length === 0,
229
+ invalidElements,
230
+ labelNames,
231
+ };
232
+ }
233
+
234
+ // -- Default Failure Handlers -----------------------------------------------
235
+
236
+ private handleRequiredInvalid(result: ValidationResult): void {
237
+ this.emitter.emit('validation:invalid', {
238
+ labelNames: result.labelNames,
239
+ elements: result.invalidElements,
240
+ });
241
+
242
+ if (this.requiredInvalidCb) {
243
+ this.requiredInvalidCb(result.labelNames, result.invalidElements);
244
+ return;
245
+ }
246
+
247
+ // Default behavior: alert + scroll
248
+ const text = result.labelNames.map((n) => `"${n}"`).join(', ');
249
+ scrollToElement(result.invalidElements[0]!);
250
+ window.alert(`${text} is required!`);
251
+ }
252
+
253
+ private handleTextareaTooLong(result: TextareaValidationResult): void {
254
+ this.emitter.emit('validation:textareaTooLong', {
255
+ labelNames: result.labelNames,
256
+ maxlengths: result.maxlengths,
257
+ elements: result.invalidElements,
258
+ });
259
+
260
+ if (this.textareaTooLongCb) {
261
+ this.textareaTooLongCb(
262
+ result.labelNames,
263
+ result.maxlengths,
264
+ result.invalidElements,
265
+ );
266
+ return;
267
+ }
268
+
269
+ // Default behavior: alert + scroll
270
+ const lines = result.labelNames.map(
271
+ (name, i) => `"${name}" length is too long, max length is ${result.maxlengths[i]}!`,
272
+ );
273
+ scrollToElement(result.invalidElements[0]!);
274
+ window.alert(lines.join('\n'));
275
+ }
276
+ }