ultimate-jekyll-manager 1.9.0 → 1.9.1
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.
- package/CHANGELOG.md +12 -0
- package/PROGRESS.md +24 -0
- package/dist/assets/js/libs/form-manager.js +15 -4
- package/dist/assets/js/pages/test/libraries/form-manager/index.js +53 -0
- package/dist/defaults/dist/pages/test/libraries/form-manager.html +72 -0
- package/dist/test/suites/page/form-manager-data.test.js +418 -0
- package/dist/test/suites/page/form-manager-disabled.test.js +170 -0
- package/dist/test/suites/page/form-manager-validation.test.js +374 -0
- package/docs/javascript-libraries.md +13 -3
- package/logs/test.log +72 -39
- package/package.json +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// FormManager disabled-state snapshot: elements disabled in HTML markup stay
|
|
2
|
+
// disabled through every FM state transition. Submit buttons (loading guards)
|
|
3
|
+
// are always FM-managed regardless of initial HTML state.
|
|
4
|
+
//
|
|
5
|
+
// Can't import FormManager (ESM + web-manager dep), so we inline the snapshot
|
|
6
|
+
// + _setDisabled logic and test against a real DOM form.
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
layer: 'page',
|
|
10
|
+
description: 'FormManager disabled-state snapshot',
|
|
11
|
+
type: 'group',
|
|
12
|
+
tests: [
|
|
13
|
+
{
|
|
14
|
+
name: 'snapshot captures disabled non-submit elements, ignores submit buttons',
|
|
15
|
+
run: async (ctx) => {
|
|
16
|
+
var form = document.createElement('form');
|
|
17
|
+
form.innerHTML = '<input type="text" name="email">'
|
|
18
|
+
+ '<input type="radio" name="plan" value="free">'
|
|
19
|
+
+ '<input type="radio" name="plan" value="enterprise" disabled>'
|
|
20
|
+
+ '<select name="region" disabled><option>US</option></select>'
|
|
21
|
+
+ '<textarea name="notes"></textarea>'
|
|
22
|
+
+ '<button type="submit" disabled>Submit</button>'
|
|
23
|
+
+ '<button type="button">Cancel</button>';
|
|
24
|
+
document.body.appendChild(form);
|
|
25
|
+
|
|
26
|
+
var permanently = new Set();
|
|
27
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
28
|
+
if ($el.disabled && $el.type !== 'submit') {
|
|
29
|
+
permanently.add($el);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
ctx.expect(permanently.size).toBe(2);
|
|
34
|
+
ctx.expect(permanently.has(form.querySelector('[value="enterprise"]'))).toBe(true);
|
|
35
|
+
ctx.expect(permanently.has(form.querySelector('[name="region"]'))).toBe(true);
|
|
36
|
+
ctx.expect(permanently.has(form.querySelector('[type="submit"]'))).toBe(false);
|
|
37
|
+
|
|
38
|
+
form.remove();
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'setDisabled(true) disables everything',
|
|
43
|
+
run: async (ctx) => {
|
|
44
|
+
var form = document.createElement('form');
|
|
45
|
+
form.innerHTML = '<input type="text" name="email">'
|
|
46
|
+
+ '<input type="radio" name="plan" value="enterprise" disabled>'
|
|
47
|
+
+ '<button type="submit" disabled>Submit</button>';
|
|
48
|
+
document.body.appendChild(form);
|
|
49
|
+
|
|
50
|
+
var permanently = new Set();
|
|
51
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
52
|
+
if ($el.disabled && $el.type !== 'submit') {
|
|
53
|
+
permanently.add($el);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
58
|
+
if (permanently.has($el)) { $el.disabled = true; return; }
|
|
59
|
+
$el.disabled = true;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
63
|
+
ctx.expect($el.disabled).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
form.remove();
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'setDisabled(false) re-enables managed elements but keeps snapshotted ones disabled',
|
|
71
|
+
run: async (ctx) => {
|
|
72
|
+
var form = document.createElement('form');
|
|
73
|
+
form.innerHTML = '<input type="text" name="email">'
|
|
74
|
+
+ '<input type="radio" name="plan" value="free">'
|
|
75
|
+
+ '<input type="radio" name="plan" value="pro">'
|
|
76
|
+
+ '<input type="radio" name="plan" value="enterprise" disabled>'
|
|
77
|
+
+ '<select name="region" disabled><option>US</option></select>'
|
|
78
|
+
+ '<textarea name="notes"></textarea>'
|
|
79
|
+
+ '<button type="submit" disabled>Submit</button>'
|
|
80
|
+
+ '<button type="button">Cancel</button>';
|
|
81
|
+
document.body.appendChild(form);
|
|
82
|
+
|
|
83
|
+
var permanently = new Set();
|
|
84
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
85
|
+
if ($el.disabled && $el.type !== 'submit') {
|
|
86
|
+
permanently.add($el);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function setDisabled(disabled) {
|
|
91
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
92
|
+
if (permanently.has($el)) { $el.disabled = true; return; }
|
|
93
|
+
$el.disabled = disabled;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setDisabled(true);
|
|
98
|
+
setDisabled(false);
|
|
99
|
+
|
|
100
|
+
ctx.expect(form.querySelector('[name="email"]').disabled).toBe(false);
|
|
101
|
+
ctx.expect(form.querySelector('[value="free"]').disabled).toBe(false);
|
|
102
|
+
ctx.expect(form.querySelector('[value="pro"]').disabled).toBe(false);
|
|
103
|
+
ctx.expect(form.querySelector('[type="submit"]').disabled).toBe(false);
|
|
104
|
+
ctx.expect(form.querySelector('[type="button"]').disabled).toBe(false);
|
|
105
|
+
ctx.expect(form.querySelector('textarea').disabled).toBe(false);
|
|
106
|
+
|
|
107
|
+
ctx.expect(form.querySelector('[value="enterprise"]').disabled).toBe(true);
|
|
108
|
+
ctx.expect(form.querySelector('[name="region"]').disabled).toBe(true);
|
|
109
|
+
|
|
110
|
+
form.remove();
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'survives multiple disable/enable cycles',
|
|
115
|
+
run: async (ctx) => {
|
|
116
|
+
var form = document.createElement('form');
|
|
117
|
+
form.innerHTML = '<input type="text" name="email">'
|
|
118
|
+
+ '<input type="radio" name="plan" value="enterprise" disabled>'
|
|
119
|
+
+ '<select name="region" disabled><option>US</option></select>'
|
|
120
|
+
+ '<button type="submit" disabled>Submit</button>';
|
|
121
|
+
document.body.appendChild(form);
|
|
122
|
+
|
|
123
|
+
var permanently = new Set();
|
|
124
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
125
|
+
if ($el.disabled && $el.type !== 'submit') {
|
|
126
|
+
permanently.add($el);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
function setDisabled(disabled) {
|
|
131
|
+
form.querySelectorAll('button, input, select, textarea').forEach(function ($el) {
|
|
132
|
+
if (permanently.has($el)) { $el.disabled = true; return; }
|
|
133
|
+
$el.disabled = disabled;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (var i = 0; i < 5; i++) {
|
|
138
|
+
setDisabled(true);
|
|
139
|
+
setDisabled(false);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ctx.expect(form.querySelector('[name="email"]').disabled).toBe(false);
|
|
143
|
+
ctx.expect(form.querySelector('[type="submit"]').disabled).toBe(false);
|
|
144
|
+
ctx.expect(form.querySelector('[value="enterprise"]').disabled).toBe(true);
|
|
145
|
+
ctx.expect(form.querySelector('[name="region"]').disabled).toBe(true);
|
|
146
|
+
|
|
147
|
+
form.remove();
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'onsubmit="return false" blocks native submission before FM loads',
|
|
152
|
+
run: async (ctx) => {
|
|
153
|
+
var form = document.createElement('form');
|
|
154
|
+
form.setAttribute('onsubmit', 'return false');
|
|
155
|
+
form.innerHTML = '<input type="text" name="x"><button type="submit">Go</button>';
|
|
156
|
+
document.body.appendChild(form);
|
|
157
|
+
|
|
158
|
+
var navigated = false;
|
|
159
|
+
form.addEventListener('submit', function (e) {
|
|
160
|
+
navigated = !e.defaultPrevented;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
form.querySelector('button').click();
|
|
164
|
+
ctx.expect(navigated).toBe(false);
|
|
165
|
+
|
|
166
|
+
form.remove();
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
};
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// FormManager validation: HTML5 constraint validation (required, email, min,
|
|
2
|
+
// max, minlength, maxlength, pattern), honeypot detection, and file-accept
|
|
3
|
+
// matching. Logic inlined from form-manager.js.
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
layer: 'page',
|
|
7
|
+
description: 'FormManager validation + honeypot + file-accept',
|
|
8
|
+
type: 'group',
|
|
9
|
+
tests: [
|
|
10
|
+
// ── Required validation ────────────────────────────────────────────
|
|
11
|
+
{
|
|
12
|
+
name: 'required text field fails when empty, passes when filled',
|
|
13
|
+
run: async (ctx) => {
|
|
14
|
+
var form = document.createElement('form');
|
|
15
|
+
form.innerHTML = '<input type="text" name="name" required>';
|
|
16
|
+
document.body.appendChild(form);
|
|
17
|
+
|
|
18
|
+
var errors = {};
|
|
19
|
+
var $f = form.querySelector('[name="name"]');
|
|
20
|
+
|
|
21
|
+
// Empty → error
|
|
22
|
+
if ($f.hasAttribute('required') && (!$f.value || !$f.value.trim())) {
|
|
23
|
+
errors['name'] = 'required';
|
|
24
|
+
}
|
|
25
|
+
ctx.expect(errors['name']).toBe('required');
|
|
26
|
+
|
|
27
|
+
// Filled → no error
|
|
28
|
+
errors = {};
|
|
29
|
+
$f.value = 'Ian';
|
|
30
|
+
if ($f.hasAttribute('required') && (!$f.value || !$f.value.trim())) {
|
|
31
|
+
errors['name'] = 'required';
|
|
32
|
+
}
|
|
33
|
+
ctx.expect(errors['name']).toBe(undefined);
|
|
34
|
+
|
|
35
|
+
form.remove();
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'required checkbox fails when unchecked',
|
|
40
|
+
run: async (ctx) => {
|
|
41
|
+
var form = document.createElement('form');
|
|
42
|
+
form.innerHTML = '<input type="checkbox" name="terms" required>';
|
|
43
|
+
document.body.appendChild(form);
|
|
44
|
+
|
|
45
|
+
var errors = {};
|
|
46
|
+
var $f = form.querySelector('[name="terms"]');
|
|
47
|
+
|
|
48
|
+
if ($f.hasAttribute('required') && $f.type === 'checkbox' && !$f.checked) {
|
|
49
|
+
errors['terms'] = 'required';
|
|
50
|
+
}
|
|
51
|
+
ctx.expect(errors['terms']).toBe('required');
|
|
52
|
+
|
|
53
|
+
$f.checked = true;
|
|
54
|
+
errors = {};
|
|
55
|
+
if ($f.hasAttribute('required') && $f.type === 'checkbox' && !$f.checked) {
|
|
56
|
+
errors['terms'] = 'required';
|
|
57
|
+
}
|
|
58
|
+
ctx.expect(errors['terms']).toBe(undefined);
|
|
59
|
+
|
|
60
|
+
form.remove();
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'required radio group fails when none checked',
|
|
65
|
+
run: async (ctx) => {
|
|
66
|
+
var form = document.createElement('form');
|
|
67
|
+
form.innerHTML = '<input type="radio" name="plan" value="a" required>'
|
|
68
|
+
+ '<input type="radio" name="plan" value="b" required>';
|
|
69
|
+
document.body.appendChild(form);
|
|
70
|
+
|
|
71
|
+
var errors = {};
|
|
72
|
+
var $checked = form.querySelector('input[name="plan"]:checked');
|
|
73
|
+
if (!$checked) {
|
|
74
|
+
errors['plan'] = 'required';
|
|
75
|
+
}
|
|
76
|
+
ctx.expect(errors['plan']).toBe('required');
|
|
77
|
+
|
|
78
|
+
form.querySelector('[value="b"]').checked = true;
|
|
79
|
+
errors = {};
|
|
80
|
+
$checked = form.querySelector('input[name="plan"]:checked');
|
|
81
|
+
if (!$checked) {
|
|
82
|
+
errors['plan'] = 'required';
|
|
83
|
+
}
|
|
84
|
+
ctx.expect(errors['plan']).toBe(undefined);
|
|
85
|
+
|
|
86
|
+
form.remove();
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// ── Email validation ───────────────────────────────────────────────
|
|
91
|
+
{
|
|
92
|
+
name: 'email validation rejects invalid formats and accepts valid ones',
|
|
93
|
+
run: async (ctx) => {
|
|
94
|
+
var emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
95
|
+
|
|
96
|
+
ctx.expect(emailPattern.test('ian@example.com')).toBe(true);
|
|
97
|
+
ctx.expect(emailPattern.test('user+tag@sub.domain.co')).toBe(true);
|
|
98
|
+
ctx.expect(emailPattern.test('bad')).toBe(false);
|
|
99
|
+
ctx.expect(emailPattern.test('no@tld')).toBe(false);
|
|
100
|
+
ctx.expect(emailPattern.test('@missing.com')).toBe(false);
|
|
101
|
+
ctx.expect(emailPattern.test('spaces @test.com')).toBe(false);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// ── Min/max value validation ───────────────────────────────────────
|
|
106
|
+
{
|
|
107
|
+
name: 'number min/max validation catches out-of-range values',
|
|
108
|
+
run: async (ctx) => {
|
|
109
|
+
var form = document.createElement('form');
|
|
110
|
+
form.innerHTML = '<input type="number" name="age" min="18" max="120">';
|
|
111
|
+
document.body.appendChild(form);
|
|
112
|
+
|
|
113
|
+
var $f = form.querySelector('[name="age"]');
|
|
114
|
+
|
|
115
|
+
function validate(value) {
|
|
116
|
+
$f.value = value;
|
|
117
|
+
if ($f.hasAttribute('min') && parseFloat($f.value) < parseFloat($f.getAttribute('min'))) return 'too-low';
|
|
118
|
+
if ($f.hasAttribute('max') && parseFloat($f.value) > parseFloat($f.getAttribute('max'))) return 'too-high';
|
|
119
|
+
return 'ok';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ctx.expect(validate('17')).toBe('too-low');
|
|
123
|
+
ctx.expect(validate('18')).toBe('ok');
|
|
124
|
+
ctx.expect(validate('50')).toBe('ok');
|
|
125
|
+
ctx.expect(validate('120')).toBe('ok');
|
|
126
|
+
ctx.expect(validate('121')).toBe('too-high');
|
|
127
|
+
|
|
128
|
+
form.remove();
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// ── Minlength / maxlength validation ───────────────────────────────
|
|
133
|
+
{
|
|
134
|
+
name: 'minlength and maxlength validation',
|
|
135
|
+
run: async (ctx) => {
|
|
136
|
+
var form = document.createElement('form');
|
|
137
|
+
form.innerHTML = '<input type="text" name="code" minlength="3" maxlength="10">';
|
|
138
|
+
document.body.appendChild(form);
|
|
139
|
+
|
|
140
|
+
var $f = form.querySelector('[name="code"]');
|
|
141
|
+
|
|
142
|
+
function validate(value) {
|
|
143
|
+
$f.value = value;
|
|
144
|
+
var minLen = parseInt($f.getAttribute('minlength'), 10);
|
|
145
|
+
var maxLen = parseInt($f.getAttribute('maxlength'), 10);
|
|
146
|
+
if ($f.value.length < minLen) return 'too-short';
|
|
147
|
+
if ($f.value.length > maxLen) return 'too-long';
|
|
148
|
+
return 'ok';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ctx.expect(validate('ab')).toBe('too-short');
|
|
152
|
+
ctx.expect(validate('abc')).toBe('ok');
|
|
153
|
+
ctx.expect(validate('abcdefghij')).toBe('ok');
|
|
154
|
+
ctx.expect(validate('abcdefghijk')).toBe('too-long');
|
|
155
|
+
|
|
156
|
+
form.remove();
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// ── Pattern validation ─────────────────────────────────────────────
|
|
161
|
+
{
|
|
162
|
+
name: 'pattern attribute validation',
|
|
163
|
+
run: async (ctx) => {
|
|
164
|
+
var form = document.createElement('form');
|
|
165
|
+
form.innerHTML = '<input type="text" name="zip" pattern="[0-9]{5}" title="5-digit zip">';
|
|
166
|
+
document.body.appendChild(form);
|
|
167
|
+
|
|
168
|
+
var $f = form.querySelector('[name="zip"]');
|
|
169
|
+
|
|
170
|
+
function validate(value) {
|
|
171
|
+
$f.value = value;
|
|
172
|
+
var pattern = new RegExp('^' + $f.getAttribute('pattern') + '$');
|
|
173
|
+
return pattern.test($f.value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
ctx.expect(validate('12345')).toBe(true);
|
|
177
|
+
ctx.expect(validate('1234')).toBe(false);
|
|
178
|
+
ctx.expect(validate('123456')).toBe(false);
|
|
179
|
+
ctx.expect(validate('abcde')).toBe(false);
|
|
180
|
+
|
|
181
|
+
form.remove();
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
// ── Honeypot detection ─────────────────────────────────────────────
|
|
186
|
+
{
|
|
187
|
+
name: 'honeypot detection catches [data-honey] and [name="honey"] fields',
|
|
188
|
+
run: async (ctx) => {
|
|
189
|
+
var HONEYPOT_SELECTOR = '[data-honey], [name="honey"]';
|
|
190
|
+
|
|
191
|
+
// Form with empty honeypot → not filled
|
|
192
|
+
var form = document.createElement('form');
|
|
193
|
+
form.innerHTML = '<input type="text" name="email" value="real">'
|
|
194
|
+
+ '<input type="text" name="honey" data-honey value="">';
|
|
195
|
+
document.body.appendChild(form);
|
|
196
|
+
|
|
197
|
+
function isHoneypotFilled(f) {
|
|
198
|
+
var pots = f.querySelectorAll(HONEYPOT_SELECTOR);
|
|
199
|
+
for (var i = 0; i < pots.length; i++) {
|
|
200
|
+
if (pots[i].value && pots[i].value.trim() !== '') return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
ctx.expect(isHoneypotFilled(form)).toBe(false);
|
|
206
|
+
|
|
207
|
+
// Fill the honeypot → detected
|
|
208
|
+
form.querySelector('[name="honey"]').value = 'bot-spam';
|
|
209
|
+
ctx.expect(isHoneypotFilled(form)).toBe(true);
|
|
210
|
+
|
|
211
|
+
form.remove();
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'honeypot detects data-honey without name="honey"',
|
|
216
|
+
run: async (ctx) => {
|
|
217
|
+
var HONEYPOT_SELECTOR = '[data-honey], [name="honey"]';
|
|
218
|
+
|
|
219
|
+
var form = document.createElement('form');
|
|
220
|
+
form.innerHTML = '<input type="text" name="realfield" value="ok">'
|
|
221
|
+
+ '<input type="text" name="decoy" data-honey value="">';
|
|
222
|
+
document.body.appendChild(form);
|
|
223
|
+
|
|
224
|
+
function isHoneypotFilled(f) {
|
|
225
|
+
var pots = f.querySelectorAll(HONEYPOT_SELECTOR);
|
|
226
|
+
for (var i = 0; i < pots.length; i++) {
|
|
227
|
+
if (pots[i].value && pots[i].value.trim() !== '') return true;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
ctx.expect(isHoneypotFilled(form)).toBe(false);
|
|
233
|
+
|
|
234
|
+
form.querySelector('[data-honey]').value = 'filled';
|
|
235
|
+
ctx.expect(isHoneypotFilled(form)).toBe(true);
|
|
236
|
+
|
|
237
|
+
form.remove();
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
// ── _fileMatchesAccept ─────────────────────────────────────────────
|
|
242
|
+
{
|
|
243
|
+
name: 'file-accept matching: extension, wildcard MIME, exact MIME',
|
|
244
|
+
run: async (ctx) => {
|
|
245
|
+
function fileMatchesAccept(fileName, fileType, accept) {
|
|
246
|
+
var types = accept.split(',').map(function (t) { return t.trim().toLowerCase(); });
|
|
247
|
+
fileName = fileName.toLowerCase();
|
|
248
|
+
fileType = (fileType || '').toLowerCase();
|
|
249
|
+
var extToCategory = {
|
|
250
|
+
'.jpg': 'image/', '.jpeg': 'image/', '.png': 'image/', '.gif': 'image/',
|
|
251
|
+
'.webp': 'image/', '.svg': 'image/', '.pdf': 'application/pdf',
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
for (var i = 0; i < types.length; i++) {
|
|
255
|
+
var type = types[i];
|
|
256
|
+
if (type.startsWith('.')) {
|
|
257
|
+
if (fileName.endsWith(type)) return true;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (type.endsWith('/*')) {
|
|
261
|
+
var prefix = type.slice(0, -2) + '/';
|
|
262
|
+
if (fileType && fileType.startsWith(prefix)) return true;
|
|
263
|
+
if (!fileType) {
|
|
264
|
+
var ext = '.' + fileName.split('.').pop();
|
|
265
|
+
var guessed = extToCategory[ext] || '';
|
|
266
|
+
if (guessed.startsWith(prefix)) return true;
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (fileType === type) return true;
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Extension matching
|
|
276
|
+
ctx.expect(fileMatchesAccept('doc.pdf', 'application/pdf', '.pdf')).toBe(true);
|
|
277
|
+
ctx.expect(fileMatchesAccept('doc.txt', 'text/plain', '.pdf')).toBe(false);
|
|
278
|
+
|
|
279
|
+
// Wildcard MIME
|
|
280
|
+
ctx.expect(fileMatchesAccept('photo.jpg', 'image/jpeg', 'image/*')).toBe(true);
|
|
281
|
+
ctx.expect(fileMatchesAccept('photo.png', 'image/png', 'image/*')).toBe(true);
|
|
282
|
+
ctx.expect(fileMatchesAccept('doc.pdf', 'application/pdf', 'image/*')).toBe(false);
|
|
283
|
+
|
|
284
|
+
// Exact MIME
|
|
285
|
+
ctx.expect(fileMatchesAccept('doc.pdf', 'application/pdf', 'application/pdf')).toBe(true);
|
|
286
|
+
ctx.expect(fileMatchesAccept('doc.pdf', 'application/pdf', 'text/plain')).toBe(false);
|
|
287
|
+
|
|
288
|
+
// Multi-accept
|
|
289
|
+
ctx.expect(fileMatchesAccept('photo.jpg', 'image/jpeg', '.pdf,image/*')).toBe(true);
|
|
290
|
+
ctx.expect(fileMatchesAccept('doc.pdf', 'application/pdf', '.pdf,image/*')).toBe(true);
|
|
291
|
+
ctx.expect(fileMatchesAccept('code.js', 'text/javascript', '.pdf,image/*')).toBe(false);
|
|
292
|
+
|
|
293
|
+
// Extension fallback when MIME type is empty
|
|
294
|
+
ctx.expect(fileMatchesAccept('photo.jpg', '', 'image/*')).toBe(true);
|
|
295
|
+
ctx.expect(fileMatchesAccept('photo.png', '', 'image/*')).toBe(true);
|
|
296
|
+
ctx.expect(fileMatchesAccept('unknown.xyz', '', 'image/*')).toBe(false);
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// ── Field error display ────────────────────────────────────────────
|
|
301
|
+
{
|
|
302
|
+
name: 'field error display adds is-invalid class and feedback element',
|
|
303
|
+
run: async (ctx) => {
|
|
304
|
+
var form = document.createElement('form');
|
|
305
|
+
form.innerHTML = '<div class="mb-3"><input type="text" name="email" class="form-control"></div>';
|
|
306
|
+
document.body.appendChild(form);
|
|
307
|
+
|
|
308
|
+
var $field = form.querySelector('[name="email"]');
|
|
309
|
+
|
|
310
|
+
// Show error
|
|
311
|
+
$field.classList.add('is-invalid');
|
|
312
|
+
var $feedback = document.createElement('div');
|
|
313
|
+
$feedback.className = 'invalid-feedback';
|
|
314
|
+
$feedback.textContent = 'Email is required';
|
|
315
|
+
$feedback.style.display = 'block';
|
|
316
|
+
$field.parentElement.appendChild($feedback);
|
|
317
|
+
|
|
318
|
+
ctx.expect($field.classList.contains('is-invalid')).toBe(true);
|
|
319
|
+
var $fb = $field.parentElement.querySelector('.invalid-feedback');
|
|
320
|
+
ctx.expect($fb).toBeTruthy();
|
|
321
|
+
ctx.expect($fb.textContent).toBe('Email is required');
|
|
322
|
+
|
|
323
|
+
// Clear error
|
|
324
|
+
$field.classList.remove('is-invalid');
|
|
325
|
+
$fb.style.display = 'none';
|
|
326
|
+
ctx.expect($field.classList.contains('is-invalid')).toBe(false);
|
|
327
|
+
|
|
328
|
+
form.remove();
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
// ── Query param population ─────────────────────────────────────────
|
|
333
|
+
{
|
|
334
|
+
name: 'query param population skips UTM/tracking params',
|
|
335
|
+
run: async (ctx) => {
|
|
336
|
+
var skipPrefixes = ['utm_', 'itm_'];
|
|
337
|
+
var skipExact = ['cb', 'fbclid', 'gclid'];
|
|
338
|
+
|
|
339
|
+
function shouldSkip(key) {
|
|
340
|
+
for (var i = 0; i < skipPrefixes.length; i++) {
|
|
341
|
+
if (key.startsWith(skipPrefixes[i])) return true;
|
|
342
|
+
}
|
|
343
|
+
return skipExact.indexOf(key) !== -1;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
ctx.expect(shouldSkip('utm_source')).toBe(true);
|
|
347
|
+
ctx.expect(shouldSkip('utm_campaign')).toBe(true);
|
|
348
|
+
ctx.expect(shouldSkip('itm_source')).toBe(true);
|
|
349
|
+
ctx.expect(shouldSkip('cb')).toBe(true);
|
|
350
|
+
ctx.expect(shouldSkip('fbclid')).toBe(true);
|
|
351
|
+
ctx.expect(shouldSkip('gclid')).toBe(true);
|
|
352
|
+
ctx.expect(shouldSkip('email')).toBe(false);
|
|
353
|
+
ctx.expect(shouldSkip('name')).toBe(false);
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
// ── State attribute ────────────────────────────────────────────────
|
|
358
|
+
{
|
|
359
|
+
name: 'data-form-state attribute reflects state transitions',
|
|
360
|
+
run: async (ctx) => {
|
|
361
|
+
var form = document.createElement('form');
|
|
362
|
+
document.body.appendChild(form);
|
|
363
|
+
|
|
364
|
+
var states = ['initializing', 'ready', 'submitting', 'ready', 'submitted'];
|
|
365
|
+
states.forEach(function (s) {
|
|
366
|
+
form.setAttribute('data-form-state', s);
|
|
367
|
+
ctx.expect(form.getAttribute('data-form-state')).toBe(s);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
form.remove();
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
};
|
|
@@ -387,12 +387,22 @@ Errors display with Bootstrap's `is-invalid` class and `.invalid-feedback` eleme
|
|
|
387
387
|
|
|
388
388
|
When the form transitions to `ready` state, FormManager automatically focuses the field with the `autofocus` attribute (if present and not disabled).
|
|
389
389
|
|
|
390
|
-
**Permanently-disabled fields (
|
|
390
|
+
**Permanently-disabled fields (snapshot model):**
|
|
391
391
|
|
|
392
|
-
FormManager
|
|
392
|
+
FormManager snapshots every element that has `disabled` in HTML markup at init time (excluding `type="submit"` buttons, which are treated as loading guards). Snapshotted elements stay disabled through every state transition — no data attributes needed. Just put `disabled` on the element in HTML:
|
|
393
393
|
|
|
394
394
|
```html
|
|
395
|
-
<input type="radio" name="plan" value="enterprise" disabled
|
|
395
|
+
<input type="radio" name="plan" value="enterprise" disabled>
|
|
396
|
+
<select name="region" disabled>...</select>
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Submit buttons can still use `disabled` in HTML as a loading guard — FM always manages them regardless of initial state. For full pre-JS protection, add `data-form-state="initializing"` and `onsubmit="return false"` to the `<form>` tag, with CSS to block interaction:
|
|
400
|
+
|
|
401
|
+
```css
|
|
402
|
+
form[data-form-state]:not([data-form-state="ready"]) {
|
|
403
|
+
pointer-events: none;
|
|
404
|
+
opacity: 0.6;
|
|
405
|
+
}
|
|
396
406
|
```
|
|
397
407
|
|
|
398
408
|
**Methods:**
|