├── .bowerrc
├── .gitignore
├── .jshintrc
├── .travis.yml
├── LICENSE
├── README.md
├── ajax-form.html
├── ajax-form.js
├── bower.json
├── demo.html
├── demo_resources
├── alertify.core.css
├── alertify.default.css
├── alertify.min.js
└── sinon-server-1.10.2.js
├── grunt_tasks
├── jshint.js
└── karma.js
├── gruntfile.js
├── index.html
├── package.json
├── test
├── index.html
└── typical-form-tests.html
└── wct.conf.js
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "../"
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bower_components
2 | .c9
3 | coverage
4 | node_modules
5 | .idea
6 | *.iml
7 | *.log
8 | dist
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "bitwise": true,
3 | "camelcase": false,
4 | "curly": true,
5 | "eqeqeq": true,
6 | "forin": true,
7 | "freeze": true,
8 | "immed": true,
9 | "indent": 4,
10 | "latedef": true,
11 | "newcap": true,
12 | "noarg": true,
13 | "noempty": false,
14 | "nonew": true,
15 | "plusplus": false,
16 | "quotmark": "single",
17 | "undef": true,
18 | "unused": false,
19 | "strict": false,
20 | "trailing": false,
21 | "maxparams": 3,
22 | "maxdepth": 3,
23 | "asi": false,
24 | "boss": false,
25 | "eqnull": true,
26 | "evil": false,
27 | "expr": true,
28 | "funcscope": false,
29 | "globalstrict": false,
30 | "iterator": false,
31 | "lastsemic": false,
32 | "laxbreak": false,
33 | "laxcomma": false,
34 | "loopfunc": false,
35 | "multistr": false,
36 | "notypeof": false,
37 | "proto": false,
38 | "scripturl": false,
39 | "smarttabs": false,
40 | "shadow": false,
41 | "sub": true,
42 | "supernew": false,
43 | "predef": [
44 | "afterEach",
45 | "beforeEach",
46 | "Blob",
47 | "clearTimeout",
48 | "console",
49 | "CustomEvent",
50 | "describe",
51 | "document",
52 | "expect",
53 | "FormData",
54 | "HTMLFormElement",
55 | "it",
56 | "setTimeout",
57 | "window",
58 | "module",
59 | "XMLHttpRequest"
60 | ]
61 | }
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '0.10'
4 | before_install:
5 | - npm install
6 | script:
7 | - $(npm bin)/grunt travis
8 | env:
9 | global:
10 | - secure: rvRcNshNze/8zhMJd3Rhiv+N9u46ljNwAuE6MtpNSaNz+jLC5e7LOXV8dyPdb3ROzDql8SztpMscaEInDvqq+JvC3W0hCYd34WAQt0ZkW0udKs0tbnkNN9dFx1gTfQFsDceTiy1D7d8fQepbeSjOn9N5M1uSUCPdTw5vMFhGFEs=
11 | - secure: ed/OB9OuOsagOo3mGd39wuE8jfHv2w09olfWMeyvmYkSoGXg0EcvYdggXAkwBAlzYbm7Tm3Mwa51BmI7O8lB5iq7X/m8ILYiKJ9nksFWEANaNWV7Px6j7EvWvjppQY1H8g7x3P6VBXIJ06+LHO0LThLIDEteAae0CVuq4vap1Vg=
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Ray Nicholus
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ajax-form
2 | =========
3 |
4 | HTML forms on performance-enhancing drugs.
5 |
6 | [](https://travis-ci.org/rnicholus/ajax-form)
7 | [](https://www.npmjs.com/package/ajax-form)
8 | [](LICENSE)
9 |
10 | **Note: Ajax-form is not tied to Polymer. In fact, it has no dependencies at all, but should work just fine with Polymer or any other custom elements library. If you prefer to use a simple custom elements polyfill, ajax-form is also your best choice.**
11 |
12 | ## What's wrong with a traditional `
40 | ```
41 |
42 | See the [API documentation page](http://ajax-form.raynicholus.com) for complete documentation and demos.
43 |
44 |
45 | ## Integration
46 | Are you developing a form field web component? Read the instructions below to ensure
47 | your field integrates properly with ajax-form.
48 |
49 | ### Submitting
50 | Your component will integrate nicely into ajax form provided your custom element
51 | exposes a `value` property that contains the current value of the field. If this
52 | is not true, then your custom field must ensure a native HTML form field is part of
53 | the light DOM. In either case, the element with the `value` property must also
54 | contain a `name` attribute. Your user/integrator will need to include an
55 | appropriate value for this field.
56 |
57 | ### Validation
58 | If your custom field exposes a native HTML form field in the light DOM, then there
59 | is nothing more to do - ajax-form will respect any validation that your user/integrator
60 | adds to the field. The constrain attribute(s) MUST be placed on the native HTML form
61 | field.
62 |
63 | If your custom field does NOT expose a native HTML form field in the light DOM by
64 | default, and you want ajax-form to respect validation constraints, then you will
65 | need to include a little code to account for this. Here are the steps to follow:
66 |
67 | 1. Add an opaque, 0x0 `` field to the light DOM, just before your field.
68 | 2. Add a `customElementRef` property to the input, with a value equal to your field.
69 | 3. Ensure the validity of the input always matches the validity of your field. You can
70 | do this via the `setCustomValidity` method present on an `HTMLInputElement`.
71 |
72 | See the [`setValidationTarget` method in the `` custom element source code](https://github.com/rnicholus/file-input/blob/1.1.4/file-input.js#L104)
73 | for an example.
74 |
75 |
76 | ## Testing
77 | ```
78 | npm install
79 | npm install -g grunt-cli
80 | grunt
81 | ```
82 |
83 | - Running `grunt` without any parameters will test against a few locally installed browsers (see the codebase for details).
84 |
85 | - Running `grunt shell:wctSauce` will run tests against a number of browsers in SauceLabs. Ensure you have your SauceLabs username and key attached to the proper environment variables first.
86 |
--------------------------------------------------------------------------------
/ajax-form.html:
--------------------------------------------------------------------------------
1 |
89 |
90 |
91 |
92 |
264 |
265 |
266 |
267 |
--------------------------------------------------------------------------------
/ajax-form.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | var arrayOf = function(pseudoArray) {
3 | return (pseudoArray && [].slice.call(pseudoArray)) || [];
4 | },
5 |
6 | // Note that _currentScript is a polyfill-specific convention
7 | currentScript = document._currentScript || document.currentScript,
8 |
9 | fire = function (node, type, _detail_) {
10 | var detail = _detail_ === null || _detail_ === undefined ? {} : _detail_,
11 | event = new CustomEvent(type, {
12 | bubbles: true,
13 | cancelable: true,
14 | detail: detail
15 | });
16 |
17 | // hack to ensure preventDefault() in IE10+ actually sets the defaultPrevented property
18 | if (customPreventDefaultIgnored) {
19 | event.preventDefault = function () {
20 | Object.defineProperty(this, 'defaultPrevented', {
21 | get: function () {
22 | return true;
23 | }
24 | });
25 | };
26 | }
27 |
28 | node.dispatchEvent(event);
29 | return event;
30 | },
31 |
32 | customPreventDefaultIgnored = (function () {
33 | var tempElement = document.createElement('div'),
34 | event = fire(tempElement, 'foobar');
35 |
36 | event.preventDefault();
37 | return !event.defaultPrevented;
38 | }()),
39 |
40 | getEnctype = function(ajaxForm) {
41 | var enctype = ajaxForm.getAttribute('enctype');
42 |
43 | return enctype || 'application/x-www-form-urlencoded';
44 | },
45 |
46 | getValidMethod = function(method) {
47 | if (method) {
48 | var proposedMethod = method.toUpperCase();
49 |
50 | if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].indexOf(proposedMethod) >= 0) {
51 | return proposedMethod;
52 | }
53 | }
54 | },
55 |
56 | importDoc = currentScript.ownerDocument,
57 |
58 | // NOTE: Safari doesn't have any visual indications when submit is blocked
59 | interceptSubmit = function(ajaxForm) {
60 | // Intercept submit event
61 | ajaxForm.addEventListener('submit', function(event) {
62 | // Stop form submission. Believe it or not,
63 | // both of these are required for some reason,
64 | // and returning false doesn't seem to reliably work.
65 | event.preventDefault();
66 | event.stopPropagation();
67 |
68 | // respect any field validation attributes
69 | if (ajaxForm.checkValidity()) {
70 | sendFormData(ajaxForm);
71 | }
72 | });
73 |
74 | // Intercept native form submit function.
75 | // In order to force the browser to highlight the invalid fields,
76 | // we need to create a hidden submit button and click it if the form is invalid.
77 | var fakeSubmitEl = document.createElement('input');
78 | fakeSubmitEl.setAttribute('type', 'submit');
79 | fakeSubmitEl.style.display = 'none';
80 | ajaxForm.appendChild(fakeSubmitEl);
81 | ajaxForm.submit = function() {
82 | ajaxForm._preventValidityCheck = false;
83 | if (ajaxForm.checkValidity()) {
84 | fire(ajaxForm, 'submit');
85 | }
86 | else {
87 | ajaxForm._preventValidityCheck = true;
88 | fakeSubmitEl.click();
89 | }
90 | };
91 | },
92 |
93 | isCheckboxOrRadioButton = function(element) {
94 | var elementType = element.type,
95 | role = element.getAttribute('role');
96 |
97 | return elementType === 'checkbox' ||
98 | role === 'checkbox' ||
99 | elementType === 'radio' ||
100 | role === 'radio';
101 | },
102 |
103 | maybeParseCoreDropdownMenu = function(customElement, data) {
104 | if (customElement.tagName.toLowerCase() === 'core-dropdown-menu' ||
105 | customElement.tagName.toLowerCase() === 'paper-dropdown-menu') {
106 | var coreMenu = customElement.querySelector('core-menu'),
107 | selectedItem = coreMenu && coreMenu.selectedItem;
108 |
109 | if (selectedItem) {
110 | processFormValue(customElement.getAttribute('name'), selectedItem.label || selectedItem.textContent, data);
111 | }
112 |
113 | return true;
114 | }
115 | },
116 |
117 | maybeParseCustomElementOrFileInput = function(spec) {
118 | var name = spec.customElement.getAttribute('name');
119 | spec.name = name;
120 |
121 | if (spec.customElement.tagName.indexOf('-') >= 0 ) {
122 | if (isCheckboxOrRadioButton(spec.customElement)) {
123 | var radioValue = parseRadioElementValue(spec.customElement);
124 | if (radioValue) {
125 | processFormValue(name, radioValue, spec.data);
126 | return true;
127 | }
128 | }
129 | else if (spec.customElement.files) {
130 | maybeParseFileInput(spec);
131 | return true;
132 | }
133 | else if (!spec.customElement.files) {
134 | processFormValue(name, spec.customElement.value, spec.data);
135 | return true;
136 | }
137 | }
138 | else if (spec.customElement.files) {
139 | maybeParseFileInput(spec);
140 | return true;
141 | }
142 | },
143 |
144 | maybeParseFileInput = function(spec) {
145 | if (spec.parseFileInputs) {
146 | processFormValue(spec.name, arrayOf(spec.customElement.files), spec.data);
147 | spec.form._fileInputFieldNames.push(spec.name);
148 | }
149 | },
150 |
151 | parseCustomElements = function(form, parseFileInputs) {
152 | var data = {};
153 |
154 | form._fileInputFieldNames = [];
155 |
156 | arrayOf(form.querySelectorAll('*[name]')).forEach(function(el) {
157 | if (maybeParseCoreDropdownMenu(el, data) ||
158 | maybeParseCustomElementOrFileInput({
159 | customElement: el,
160 | data: data,
161 | form: form,
162 | parseFileInputs: parseFileInputs
163 | })
164 | ) {
165 | arrayOf(el.querySelectorAll('[name]')).forEach(function(el) {
166 | el.setAttribute('data-ajaxform-ignore', '');
167 | });
168 | }
169 | });
170 |
171 | return data;
172 | },
173 |
174 | /**
175 | * Return the value of some `HTMLElement`s value attribute if possible.
176 | * @param HTMLElement element
177 | * @return mixed The element's value attribute
178 | */
179 | parseElementValue = function(element){
180 | var elementValue,
181 | elementTag = element.tagName.toLowerCase();
182 |
183 | if (elementTag === 'input' && element.type !== 'file') {
184 | elementValue = parseInputElementValue(element);
185 | }
186 | else if (elementTag === 'textarea') {
187 | elementValue = element.value || '';
188 | }
189 | else if (elementTag === 'select') {
190 | elementValue = parseSelectElementValues(element);
191 | }
192 |
193 | return elementValue;
194 | },
195 |
196 | parseForm = function(form, parseFileInputs) {
197 | var formObj = {},
198 | customElementsData = parseCustomElements(form, parseFileInputs),
199 | formElements = arrayOf(form.elements).filter(function(el) {
200 | return !el.hasAttribute('data-ajaxform-ignore');
201 | });
202 |
203 |
204 | formElements.forEach(function(formElement) {
205 | var key = formElement.name,
206 | val = parseElementValue(formElement);
207 |
208 | if (key && val != null) {
209 | processFormValue(key, val, formObj);
210 | }
211 | });
212 |
213 | Object.keys(customElementsData).forEach(function(fieldName) {
214 | processFormValue(fieldName, customElementsData[fieldName], formObj);
215 | });
216 |
217 | return formObj;
218 | },
219 |
220 | parseInputElementValue = function(element){
221 | var elementValue,
222 | elementType = element.type;
223 |
224 | if (element.disabled === true ||
225 | ['submit', 'reset', 'button', 'image'].indexOf(elementType) !== -1) {
226 | // do nothing for these button types
227 | }
228 | // support checkboxes, radio buttons or elements that behave as such
229 | else if (isCheckboxOrRadioButton(element)) {
230 | elementValue = parseRadioElementValue(element);
231 | }
232 | else {
233 | elementValue = element.value || '';
234 | }
235 |
236 | return elementValue;
237 | },
238 |
239 | parseRadioElementValue = function(element) {
240 | var value;
241 | if (element.checked === true) {
242 | value = element.value;
243 | }
244 | return value;
245 | },
246 |
247 | parseSelectOptionElementValue = function(element) {
248 | var elementValue;
249 | if (element.selected === true){
250 | elementValue = element.value;
251 | }
252 | return elementValue;
253 | },
254 |
255 | parseSelectElementValues = function(element) {
256 | var elementValues = [];
257 |
258 | arrayOf(element.options).forEach(function(optionElement){
259 | var tempElementValue = parseSelectOptionElementValue(optionElement);
260 | tempElementValue && elementValues.push(tempElementValue);
261 | });
262 |
263 | return elementValues;
264 | },
265 |
266 | processFormValue = function(key, value, store) {
267 | if (store[key]) {
268 | if (Array.isArray(store[key]) &&
269 | store[key].length > 1 &&
270 | Array.isArray(store[key][1])) {
271 |
272 | store[key].push([value]);
273 | }
274 | else {
275 | store[key] = [[store[key]]];
276 | store[key].push([value]);
277 | }
278 | }
279 | else {
280 | store[key] = value;
281 | }
282 | },
283 |
284 | sendFormData = function(ajaxForm) {
285 | var enctype = getEnctype(ajaxForm),
286 | formData = parseForm(ajaxForm, enctype === 'multipart/form-data'),
287 | submittingEvent = fire(ajaxForm, 'submitting', {formData: formData});
288 |
289 | if (!submittingEvent.defaultPrevented) {
290 | formData = submittingEvent.detail.formData;
291 |
292 | if ('multipart/form-data' !== enctype &&
293 | 'application/json' !== enctype) {
294 |
295 | sendUrlencodedForm(ajaxForm, formData);
296 | }
297 | else {
298 | if ('GET' === ajaxForm.acceptableMethod) {
299 | sendUrlencodedForm(ajaxForm, formData);
300 | }
301 | else if ('multipart/form-data' === enctype) {
302 | sendMultipartForm(ajaxForm, formData);
303 | }
304 | else if ('application/json' === enctype) {
305 | sendJsonEncodedForm(ajaxForm, formData);
306 | }
307 | }
308 | }
309 | },
310 |
311 | sendJsonEncodedForm = function(ajaxForm, data) {
312 | sendRequest({
313 | body: JSON.stringify(data),
314 | contentType: getEnctype(ajaxForm),
315 | form: ajaxForm
316 | });
317 | },
318 |
319 | sendMultipartForm = function(ajaxForm, data) {
320 | var formData = new FormData();
321 |
322 | Object.keys(data).forEach(function(fieldName) {
323 | var fieldValue = data[fieldName];
324 |
325 | if (Array.isArray(fieldValue)) {
326 | // If this is a file input field value, and there are no
327 | // selected files, ensure this is accounted for in the
328 | // request as an empty filename w/ an empty application/octet-stream
329 | // boundary body. This is how a native form submit accounts for an
330 | // empty file input.
331 | if (fieldValue.length === 0 &&
332 | ajaxForm._fileInputFieldNames.indexOf(fieldName) >= 0) {
333 |
334 | formData.append(fieldName,
335 | new Blob([], {type : 'application/octet-stream'}), '');
336 | }
337 | else {
338 | fieldValue.forEach(function(file) {
339 | formData.append(fieldName, file);
340 | });
341 | }
342 | }
343 | else {
344 | formData.append(fieldName, data[fieldName]);
345 | }
346 | });
347 |
348 | sendRequest({
349 | body: formData,
350 | form: ajaxForm
351 | });
352 | },
353 |
354 | sendRequest = function(options) {
355 | var xhr = new XMLHttpRequest(),
356 | customHeaders = options.form.getAttribute('headers');
357 |
358 | xhr.open(options.form.acceptableMethod, options.url || options.form.action);
359 |
360 | xhr.withCredentials = !!options.form.cookies;
361 |
362 | if (customHeaders) {
363 | if (typeof(customHeaders) === 'string') {
364 | customHeaders = JSON.parse(customHeaders);
365 | }
366 |
367 | Object.keys(customHeaders).forEach(function(headerName) {
368 | xhr.setRequestHeader(headerName, customHeaders[headerName]);
369 | });
370 | }
371 |
372 | options.contentType && xhr.setRequestHeader('Content-Type', options.contentType);
373 |
374 | xhr.onreadystatechange = function() {
375 | if (xhr.readyState === 4) {
376 | fire(options.form, 'submitted', xhr);
377 | }
378 | };
379 |
380 | xhr.send(options.body);
381 | },
382 |
383 | sendUrlencodedForm = function(ajaxForm, formData) {
384 | var data = toQueryString(formData);
385 |
386 | if (ajaxForm.acceptableMethod === 'POST') {
387 | sendRequest({
388 | body: data,
389 | contentType: getEnctype(ajaxForm),
390 | form: ajaxForm
391 | });
392 | }
393 | else {
394 | sendRequest({
395 | contentType: getEnctype(ajaxForm),
396 | form: ajaxForm,
397 | url: ajaxForm.action + (ajaxForm.action.indexOf('?') > 0 ? '&' : '?') + data
398 | });
399 | }
400 | },
401 |
402 | toQueryString = function(params) {
403 | var queryParams = [];
404 |
405 | Object.keys(params).forEach(function(key) {
406 | var val = params[key];
407 | key = encodeURIComponent(key);
408 |
409 | if (val && Object.prototype.toString.call(val) === '[object Array]') {
410 | val.forEach(function(valInArray) {
411 | queryParams.push(key + '=' + encodeURIComponent(valInArray));
412 | });
413 | }
414 | else {
415 | queryParams.push(val == null ? key : (key + '=' + encodeURIComponent(val)));
416 | }
417 | });
418 |
419 | return queryParams.join('&');
420 | },
421 |
422 | watchForInvalidFields = function (ajaxForm) {
423 | var config = {attributes: true, childList: true, characterData: false},
424 | initialFields = arrayOf(ajaxForm.elements),
425 | invalidFields = [],
426 |
427 | listenForInvalidEvent = function (field) {
428 | field.willValidate && field.addEventListener('invalid', function () {
429 | if (ajaxForm._preventValidityCheck) {
430 | return;
431 | }
432 |
433 | invalidFields.push(field.customElementRef || field);
434 |
435 | // In case another element is invalid and the event
436 | // hasn't been triggered yet, hold off on firing the
437 | // invalid event on the custom el.
438 | clearTimeout(timer);
439 | timer = setTimeout(function () {
440 | fire(ajaxForm, 'invalid', invalidFields);
441 | invalidFields = [];
442 | console.error('Form submission blocked - constraints violation.');
443 | }, 10);
444 | });
445 | },
446 |
447 | // Be sure to observe any validatable form fields added in the future
448 | mutationHandler = new window.MutationObserver(function (records) {
449 | records.forEach(function (record) {
450 | if (record.addedNodes.length) {
451 | arrayOf(record.addedNodes).forEach(function (addedNode) {
452 | addedNode.willValidate && listenForInvalidEvent(addedNode);
453 | });
454 | }
455 | });
456 | }),
457 |
458 | timer = null;
459 |
460 | initialFields.forEach(function (field) {
461 | listenForInvalidEvent(field);
462 | });
463 |
464 | // pass in the target node, as well as the observer options
465 | mutationHandler.observe(ajaxForm, config);
466 |
467 | };
468 |
469 | document.registerElement('ajax-form', {
470 | extends: 'form',
471 | prototype: Object.create(HTMLFormElement.prototype, {
472 | createdCallback: {
473 | value: function () {
474 | var templates = importDoc.querySelectorAll('.ajax-form-template'),
475 | template = templates[templates.length - 1],
476 | clone = document.importNode(template.content, true);
477 |
478 | this.appendChild(clone);
479 |
480 | var ajaxForm = this;
481 |
482 | // The method attribute set on the light-DOM `