diff --git a/UNSAVED_CHANGES_POC.md b/UNSAVED_CHANGES_POC.md new file mode 100644 index 0000000..f88af8d --- /dev/null +++ b/UNSAVED_CHANGES_POC.md @@ -0,0 +1,338 @@ +# Unsaved Form Changes Warning - Proof of Concept + +**Related Issue:** [#2 - JavaScript - show alert before leaving page](https://github.com/contributte/forms/issues/2) + +This PoC demonstrates a feature that warns users when they attempt to navigate away from a page with unsaved form changes. The implementation is based on the browser's `beforeunload` event and works with both vanilla HTML forms and Nette Forms. + +## Features + +- ✅ **Automatic change detection** - Tracks all form field changes in real-time +- ✅ **Browser confirmation dialog** - Shows native browser warning before leaving +- ✅ **Multiple initialization methods** - Declarative (HTML attributes) or programmatic (JavaScript) +- ✅ **Nette Forms integration** - Easy-to-use PHP helper class +- ✅ **Customizable messages** - Set custom warning text +- ✅ **Smart form submission** - Automatically clears warning on form submit +- ✅ **Debug mode** - Console logging for development +- ✅ **Zero dependencies** - Pure vanilla JavaScript, no jQuery required +- ✅ **Framework agnostic** - Works with any form, not just Nette + +## Files Added + +``` +assets/ + └── unsaved-changes.js # Core JavaScript tracker + +src/Controls/ + └── UnsavedChangesControl.php # PHP helper for Nette Forms + +examples/ + ├── unsaved-changes-demo.html # HTML demo with multiple examples + └── unsaved-changes-nette.php # Nette Forms integration demo +``` + +## Usage + +### 1. HTML Forms (Declarative) + +The simplest way to enable the warning is using a data attribute: + +```html +
+ + + +
+ + +``` + +**With debug mode:** + +```html +
+ +
+``` + +### 2. JavaScript (Programmatic) + +For more control, initialize the tracker manually: + +```javascript +var tracker = new UnsavedChangesTracker('#myForm', { + message: 'You have unsaved changes. Are you sure you want to leave?', + debug: true, + trackInputs: true, + trackTextareas: true, + trackSelects: true, + trackCheckboxes: true, + trackRadios: true, + resetOnSubmit: true +}); + +// Check if form is dirty +if (tracker.isDirty) { + console.log('Form has unsaved changes'); +} + +// Manually reset the tracker +tracker.reset(); + +// Manually mark as dirty +tracker.setDirty(true); + +// Destroy the tracker +tracker.destroy(); +``` + +### 3. Nette Forms (PHP) + +Use the `UnsavedChangesControl` helper class: + +```php +use Nette\Forms\Form; +use Contributte\Forms\Controls\UnsavedChangesControl; +use Contributte\Forms\Rendering\Bootstrap5VerticalRenderer; + +$form = new Form(); +$form->addText('name', 'Name:'); +$form->addEmail('email', 'Email:'); +$form->addSubmit('submit', 'Submit'); + +// Enable unsaved changes warning (default message) +UnsavedChangesControl::enable($form); + +// Or with custom message +UnsavedChangesControl::enable($form, 'You have unsaved changes!'); + +// With debug mode +UnsavedChangesControl::enable($form, 'Custom message', debug: true); + +// With advanced options +UnsavedChangesControl::enableWithOptions($form, [ + 'message' => 'Unsaved changes detected!', + 'debug' => true, +]); + +// Disable the warning +UnsavedChangesControl::disable($form); + +// Render form (works with any renderer) +$form->setRenderer(new Bootstrap5VerticalRenderer()); +echo $form; +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `message` | string | "You have unsaved changes..." | Warning message shown to user | +| `trackInputs` | boolean | `true` | Track text input fields | +| `trackTextareas` | boolean | `true` | Track textarea fields | +| `trackSelects` | boolean | `true` | Track select dropdowns | +| `trackCheckboxes` | boolean | `true` | Track checkboxes | +| `trackRadios` | boolean | `true` | Track radio buttons | +| `excludeSelectors` | array | `[]` | Array of CSS selectors to exclude | +| `resetOnSubmit` | boolean | `true` | Clear warning on form submit | +| `debug` | boolean | `false` | Enable console logging | + +## How It Works + +1. **Initial State** - When initialized, the tracker stores the current value of all tracked form fields +2. **Change Detection** - Listens to `input` and `change` events on form fields +3. **Dirty Flag** - Sets an internal `isDirty` flag when any field value differs from its initial value +4. **beforeunload Handler** - Attaches a `beforeunload` event listener that shows a confirmation dialog if the form is dirty +5. **Form Submit** - On form submission, the dirty flag is cleared to allow navigation without warning + +## Browser Compatibility + +The `beforeunload` event is supported in all modern browsers: + +- ✅ Chrome/Edge 1+ +- ✅ Firefox 1+ +- ✅ Safari 3+ +- ✅ Opera 12+ +- ✅ Internet Explorer 4+ + +**Note:** Modern browsers (Chrome 51+, Firefox 44+) ignore custom messages in the confirmation dialog for security reasons. They display a generic browser-provided message instead. However, the feature still works correctly. + +## Testing the PoC + +### Option 1: HTML Demo + +1. Open `examples/unsaved-changes-demo.html` in your browser +2. Fill out any form on the page +3. Try to close the tab or navigate away +4. You'll see a browser confirmation dialog + +### Option 2: Nette Forms Demo + +1. Make sure composer dependencies are installed: + ```bash + composer install + ``` + +2. Start a PHP development server: + ```bash + php -S localhost:8000 -t examples/ + ``` + +3. Open `http://localhost:8000/unsaved-changes-nette.php` in your browser + +4. Fill out the form and try to navigate away + +### Option 3: Integration Test + +Create your own test file: + +```html + + + + Test + + +
+ + +
+ + + +``` + +## API Reference + +### Constructor + +```javascript +new UnsavedChangesTracker(form, options) +``` + +- `form` - HTMLFormElement or CSS selector string +- `options` - Configuration object (see Configuration Options above) + +### Methods + +#### `reset()` +Resets the tracker and marks the form as clean. + +```javascript +tracker.reset(); +``` + +#### `setDirty(dirty)` +Manually set the dirty flag. + +```javascript +tracker.setDirty(true); // Mark as dirty +tracker.setDirty(false); // Mark as clean +``` + +#### `isDirtyForm()` +Returns whether the form has unsaved changes. + +```javascript +if (tracker.isDirtyForm()) { + console.log('Form has changes'); +} +``` + +#### `checkChanges()` +Manually check if form has changes and update the dirty flag. + +```javascript +var hasChanges = tracker.checkChanges(); +``` + +#### `destroy()` +Destroys the tracker and cleans up. + +```javascript +tracker.destroy(); +``` + +### Properties + +#### `isDirty` +Boolean flag indicating whether the form has unsaved changes. + +```javascript +console.log(tracker.isDirty); // true or false +``` + +## Integration with Existing Projects + +### Adding to Your Project + +1. **Copy the JavaScript file:** + ```bash + cp assets/unsaved-changes.js public/js/ + ``` + +2. **Include in your layout/template:** + ```html + + ``` + +3. **For Nette projects, copy the PHP helper:** + ```bash + cp src/Controls/UnsavedChangesControl.php your-project/src/Forms/ + ``` + +### With Asset Management + +If using Webpack, Vite, or similar: + +```javascript +import UnsavedChangesTracker from './unsaved-changes.js'; + +const tracker = new UnsavedChangesTracker('#myForm', { + message: 'You have unsaved changes!' +}); +``` + +## Potential Improvements + +This is a PoC. For production use, consider: + +- [ ] **NPM package** - Publish as a standalone package +- [ ] **TypeScript version** - Add type definitions +- [ ] **Framework integrations** - React, Vue, Angular adapters +- [ ] **Advanced options** - Exclude specific fields, custom validators +- [ ] **Callbacks** - `onDirty`, `onChange`, `onBeforeUnload` hooks +- [ ] **AJAX forms** - Integration with AJAX submissions +- [ ] **Multiple forms** - Support tracking multiple forms independently +- [ ] **localStorage** - Auto-save drafts to localStorage +- [ ] **Visual indicator** - Show a visual "unsaved changes" badge +- [ ] **Unit tests** - Automated testing suite +- [ ] **Minified version** - Production-ready minified build + +## Known Limitations + +1. **Custom message ignored** - Modern browsers show a generic message for security +2. **No mobile support** - Mobile browsers don't support `beforeunload` reliably +3. **SPA routing** - Doesn't work with client-side routing (React Router, Vue Router, etc.) without additional setup +4. **File inputs** - File input changes are tracked but the actual file isn't stored +5. **Dynamic forms** - Fields added after initialization aren't automatically tracked + +## Security Considerations + +- ✅ **No data storage** - Doesn't store or transmit form data +- ✅ **Client-side only** - All tracking happens in the browser +- ✅ **No external dependencies** - No third-party scripts loaded +- ✅ **XSS safe** - Custom messages are not rendered as HTML + +## License + +MIT (same as contributte/forms) + +## Contributing + +This is a proof of concept for issue #2. Feedback and suggestions are welcome! + +## Related Resources + +- [MDN: beforeunload event](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) +- [Nette Forms Documentation](https://doc.nette.org/en/forms) +- [Contributte Forms Documentation](https://contributte.org/packages/contributte/forms.html) diff --git a/assets/unsaved-changes.js b/assets/unsaved-changes.js new file mode 100644 index 0000000..65d2b63 --- /dev/null +++ b/assets/unsaved-changes.js @@ -0,0 +1,302 @@ +/** + * Unsaved Form Changes Tracker + * + * Monitors form changes and displays a confirmation dialog when the user + * attempts to navigate away from the page with unsaved changes. + * + * @author Contributte Forms + * @license MIT + */ +(function() { + 'use strict'; + + /** + * UnsavedChangesTracker constructor + * @param {HTMLFormElement|string} form - Form element or selector + * @param {Object} options - Configuration options + */ + function UnsavedChangesTracker(form, options) { + // Default options + this.options = Object.assign({ + message: 'You have unsaved changes. Are you sure you want to leave?', + trackInputs: true, + trackTextareas: true, + trackSelects: true, + trackCheckboxes: true, + trackRadios: true, + excludeSelectors: [], // Array of selectors to exclude from tracking + resetOnSubmit: true, + debug: false + }, options || {}); + + // Get form element + if (typeof form === 'string') { + this.form = document.querySelector(form); + } else { + this.form = form; + } + + if (!this.form) { + console.error('UnsavedChangesTracker: Form not found'); + return; + } + + this.isDirty = false; + this.initialValues = {}; + + this.init(); + } + + /** + * Initialize the tracker + */ + UnsavedChangesTracker.prototype.init = function() { + this.log('Initializing tracker for form', this.form); + + // Store initial values + this.storeInitialValues(); + + // Attach change listeners + this.attachListeners(); + + // Attach beforeunload handler + this.attachBeforeUnloadHandler(); + }; + + /** + * Store initial form values + */ + UnsavedChangesTracker.prototype.storeInitialValues = function() { + var self = this; + var elements = this.getTrackedElements(); + + elements.forEach(function(element) { + var key = self.getElementKey(element); + self.initialValues[key] = self.getElementValue(element); + }); + + this.log('Initial values stored:', this.initialValues); + }; + + /** + * Get all tracked form elements + */ + UnsavedChangesTracker.prototype.getTrackedElements = function() { + var selectors = []; + + if (this.options.trackInputs) { + selectors.push('input[type="text"]', 'input[type="email"]', 'input[type="password"]', + 'input[type="number"]', 'input[type="tel"]', 'input[type="url"]', + 'input[type="date"]', 'input[type="datetime-local"]', 'input[type="time"]', + 'input[type="search"]', 'input[type="color"]', 'input[type="hidden"]'); + } + + if (this.options.trackTextareas) { + selectors.push('textarea'); + } + + if (this.options.trackSelects) { + selectors.push('select'); + } + + if (this.options.trackCheckboxes) { + selectors.push('input[type="checkbox"]'); + } + + if (this.options.trackRadios) { + selectors.push('input[type="radio"]'); + } + + var elements = this.form.querySelectorAll(selectors.join(',')); + var filtered = []; + + // Filter out excluded elements + for (var i = 0; i < elements.length; i++) { + var element = elements[i]; + var excluded = false; + + for (var j = 0; j < this.options.excludeSelectors.length; j++) { + if (element.matches(this.options.excludeSelectors[j])) { + excluded = true; + break; + } + } + + if (!excluded) { + filtered.push(element); + } + } + + return filtered; + }; + + /** + * Get unique key for an element + */ + UnsavedChangesTracker.prototype.getElementKey = function(element) { + return element.name || element.id || 'element_' + Array.prototype.indexOf.call(this.form.elements, element); + }; + + /** + * Get element value + */ + UnsavedChangesTracker.prototype.getElementValue = function(element) { + if (element.type === 'checkbox') { + return element.checked; + } else if (element.type === 'radio') { + var radios = this.form.querySelectorAll('input[name="' + element.name + '"]'); + for (var i = 0; i < radios.length; i++) { + if (radios[i].checked) { + return radios[i].value; + } + } + return null; + } else { + return element.value; + } + }; + + /** + * Attach change listeners to form elements + */ + UnsavedChangesTracker.prototype.attachListeners = function() { + var self = this; + var elements = this.getTrackedElements(); + + elements.forEach(function(element) { + // Use 'input' event for real-time tracking on text inputs + if (element.tagName === 'INPUT' && + ['text', 'email', 'password', 'number', 'tel', 'url', 'search'].indexOf(element.type) !== -1) { + element.addEventListener('input', function() { + self.checkChanges(); + }); + } + + // Use 'change' event for other elements + element.addEventListener('change', function() { + self.checkChanges(); + }); + }); + + // Handle form submit + if (this.options.resetOnSubmit) { + this.form.addEventListener('submit', function() { + self.log('Form submitted, resetting dirty flag'); + self.reset(); + }); + } + + this.log('Attached listeners to', elements.length, 'elements'); + }; + + /** + * Check if form has changes + */ + UnsavedChangesTracker.prototype.checkChanges = function() { + var self = this; + var elements = this.getTrackedElements(); + var hasChanges = false; + + elements.forEach(function(element) { + var key = self.getElementKey(element); + var currentValue = self.getElementValue(element); + var initialValue = self.initialValues[key]; + + if (currentValue !== initialValue) { + hasChanges = true; + } + }); + + this.isDirty = hasChanges; + this.log('Form dirty status:', this.isDirty); + + return hasChanges; + }; + + /** + * Attach beforeunload handler + */ + UnsavedChangesTracker.prototype.attachBeforeUnloadHandler = function() { + var self = this; + + window.addEventListener('beforeunload', function(event) { + if (self.isDirty) { + self.log('Preventing navigation - unsaved changes detected'); + + // Modern browsers ignore custom messages, but we set it anyway + event.preventDefault(); + event.returnValue = self.options.message; + return self.options.message; + } + }); + }; + + /** + * Reset the tracker (mark as clean) + */ + UnsavedChangesTracker.prototype.reset = function() { + this.isDirty = false; + this.storeInitialValues(); + this.log('Tracker reset'); + }; + + /** + * Mark form as dirty + */ + UnsavedChangesTracker.prototype.setDirty = function(dirty) { + this.isDirty = dirty !== false; + this.log('Dirty flag manually set to:', this.isDirty); + }; + + /** + * Check if form is dirty + */ + UnsavedChangesTracker.prototype.isDirtyForm = function() { + return this.isDirty; + }; + + /** + * Destroy the tracker + */ + UnsavedChangesTracker.prototype.destroy = function() { + this.isDirty = false; + this.initialValues = {}; + this.log('Tracker destroyed'); + }; + + /** + * Debug logging + */ + UnsavedChangesTracker.prototype.log = function() { + if (this.options.debug) { + console.log.apply(console, ['[UnsavedChangesTracker]'].concat(Array.prototype.slice.call(arguments))); + } + }; + + // Export to global scope + if (typeof module !== 'undefined' && module.exports) { + module.exports = UnsavedChangesTracker; + } else { + window.UnsavedChangesTracker = UnsavedChangesTracker; + } + + // Auto-initialize forms with data-unsaved-warning attribute + if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', function() { + var forms = document.querySelectorAll('form[data-unsaved-warning]'); + + forms.forEach(function(form) { + var message = form.getAttribute('data-unsaved-warning'); + var options = { + debug: form.hasAttribute('data-unsaved-debug') + }; + + if (message && message !== 'true') { + options.message = message; + } + + new UnsavedChangesTracker(form, options); + }); + }); + } +})(); diff --git a/examples/unsaved-changes-demo.html b/examples/unsaved-changes-demo.html new file mode 100644 index 0000000..f1d915f --- /dev/null +++ b/examples/unsaved-changes-demo.html @@ -0,0 +1,218 @@ + + + + + + Unsaved Changes Warning - Demo + + + + +
+

Unsaved Form Changes Warning - PoC Demo

+ +
+
How to test:
+
    +
  1. Fill out any form below
  2. +
  3. Try to close the browser tab or navigate away
  4. +
  5. You'll see a browser confirmation dialog warning about unsaved changes
  6. +
  7. Submit the form to clear the warning
  8. +
+
+ + +
+

Example 1: Auto-initialization (Declarative)

+

This form uses data-unsaved-warning attribute for automatic initialization.

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + +
+

Example 2: Manual Initialization (Programmatic)

+

This form is initialized programmatically with custom options and status indicator.

+ +
+ Status: No unsaved changes +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ +
+ + +
+ + + +
+
+ + +
+

Example 3: Debug Mode

+

This form has debug mode enabled. Open your browser console to see tracking events.

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+

Code Examples

+ +

HTML (Declarative):

+
<form data-unsaved-warning="Your custom message here">
+    <!-- form fields -->
+</form>
+ +

JavaScript (Programmatic):

+
var tracker = new UnsavedChangesTracker('#myForm', {
+    message: 'You have unsaved changes!',
+    debug: true
+});
+ +

PHP (with Nette Forms):

+
use Contributte\Forms\Controls\UnsavedChangesControl;
+
+$form = new Form();
+$form->addText('name', 'Name');
+$form->addEmail('email', 'Email');
+$form->addSubmit('submit', 'Submit');
+
+// Enable unsaved changes warning
+UnsavedChangesControl::enable($form, 'You have unsaved changes!');
+
+echo $form;
+
+
+ + + + + + + + diff --git a/examples/unsaved-changes-nette.php b/examples/unsaved-changes-nette.php new file mode 100644 index 0000000..842bb4a --- /dev/null +++ b/examples/unsaved-changes-nette.php @@ -0,0 +1,150 @@ +addText('name', 'Name:') + ->setRequired('Please enter your name'); + +$form->addEmail('email', 'Email:') + ->setRequired('Please enter your email'); + +$form->addTextArea('message', 'Message:') + ->setRequired('Please enter a message') + ->setAttribute('rows', 5); + +$form->addSelect('priority', 'Priority:', [ + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', +]) + ->setPrompt('Select priority'); + +$form->addCheckbox('subscribe', 'Subscribe to newsletter'); + +$form->addSubmit('submit', 'Submit'); + +// Enable unsaved changes warning - Example 1: Simple +// UnsavedChangesControl::enable($form); + +// Enable unsaved changes warning - Example 2: With custom message +UnsavedChangesControl::enable($form, 'You have unsaved changes. Are you sure you want to leave?'); + +// Enable unsaved changes warning - Example 3: With debug mode +// UnsavedChangesControl::enable($form, 'Custom warning!', debug: true); + +// Enable unsaved changes warning - Example 4: With custom options +// UnsavedChangesControl::enableWithOptions($form, [ +// 'message' => 'You have unsaved changes!', +// 'debug' => true, +// 'trackCheckboxes' => true, +// 'trackRadios' => true, +// ]); + +// Use Bootstrap 5 renderer +$form->setRenderer(new Bootstrap5VerticalRenderer()); + +// Handle form submission +if ($form->isSuccess()) { + $values = $form->getValues(); + + // Process form data... + echo '
Form submitted successfully!
'; + echo '
' . print_r($values, true) . '
'; + exit; +} + +?> + + + + + + Nette Forms - Unsaved Changes Demo + + + + +
+

Nette Forms + Unsaved Changes Warning

+ +
+ Test Instructions: +
    +
  1. Fill out the form below
  2. +
  3. Try to close the tab or navigate away
  4. +
  5. You'll see a confirmation dialog about unsaved changes
  6. +
  7. Click "Submit" to save and clear the warning
  8. +
+
+ +
+ Note: This is a demonstration. The form is rendered with Bootstrap 5 + and the unsaved changes warning is enabled using + UnsavedChangesControl::enable($form). +
+ + + +
+ +

Code Example

+
<?php
+use Nette\Forms\Form;
+use Contributte\Forms\Controls\UnsavedChangesControl;
+use Contributte\Forms\Rendering\Bootstrap5VerticalRenderer;
+
+$form = new Form();
+$form->addText('name', 'Name:');
+$form->addEmail('email', 'Email:');
+$form->addSubmit('submit', 'Submit');
+
+// Enable unsaved changes warning
+UnsavedChangesControl::enable($form, 'Custom message here!');
+
+// Use Bootstrap renderer
+$form->setRenderer(new Bootstrap5VerticalRenderer());
+
+echo $form;
+?>
+
+ + + + + diff --git a/src/Controls/UnsavedChangesControl.php b/src/Controls/UnsavedChangesControl.php new file mode 100644 index 0000000..48c4bcf --- /dev/null +++ b/src/Controls/UnsavedChangesControl.php @@ -0,0 +1,84 @@ +getElementPrototype(); + + // Add data attribute to enable auto-initialization + if ($message === true) { + $prototype->setAttribute('data-unsaved-warning', 'true'); + } else { + $prototype->setAttribute('data-unsaved-warning', $message); + } + + if ($debug) { + $prototype->setAttribute('data-unsaved-debug', 'true'); + } + } + + /** + * Enable unsaved changes warning with custom options + * + * @param Form $form Form to enable warning on + * @param array $options Custom options for the tracker + */ + public static function enableWithOptions(Form $form, array $options = []): void + { + $prototype = $form->getElementPrototype(); + + // Add data attribute for auto-initialization + $prototype->setAttribute('data-unsaved-warning', 'true'); + + // Add custom options as data attributes + if (isset($options['message'])) { + $prototype->setAttribute('data-unsaved-warning', $options['message']); + } + + if (isset($options['debug']) && $options['debug']) { + $prototype->setAttribute('data-unsaved-debug', 'true'); + } + + // Store additional options in data-unsaved-options as JSON + $additionalOptions = array_diff_key($options, ['message' => null, 'debug' => null]); + if (!empty($additionalOptions)) { + $prototype->setAttribute('data-unsaved-options', json_encode($additionalOptions)); + } + } + + /** + * Disable unsaved changes warning on a form + * + * @param Form $form Form to disable warning on + */ + public static function disable(Form $form): void + { + $prototype = $form->getElementPrototype(); + $prototype->removeAttribute('data-unsaved-warning'); + $prototype->removeAttribute('data-unsaved-debug'); + $prototype->removeAttribute('data-unsaved-options'); + } + +}