-
Notifications
You must be signed in to change notification settings - Fork 0
Allow dynamic grid rows #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
174922c
95eb6d7
98183f2
3844e5a
dd6e7f5
fad2913
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -93,6 +93,12 @@ export default function formFields(fields, honeypot, hideFields, prepopulatedDat | |||||||||||
| }, | ||||||||||||
|
|
||||||||||||
| initializeFields(fields) { | ||||||||||||
| // Pre-compute handle→default map for O(1) lookups (used by dynamic_rows_field) | ||||||||||||
| const defaultsByHandle = Object.create(null) | ||||||||||||
| for (const f of fields) { | ||||||||||||
| defaultsByHandle[f.handle] = f.default | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| return fields.reduce((acc, field) => { | ||||||||||||
| // Handle group fields - initialize nested fields with dot notation | ||||||||||||
| if (field.type === 'group' && field.group_fields) { | ||||||||||||
|
|
@@ -105,7 +111,20 @@ export default function formFields(fields, honeypot, hideFields, prepopulatedDat | |||||||||||
|
|
||||||||||||
| // Handle grid fields - initialize with flat indexed keys | ||||||||||||
| if (field.type === 'grid' && field.grid_fields) { | ||||||||||||
| const rowCount = field.fixed_rows || field.min_rows || 1 | ||||||||||||
| let rowCount | ||||||||||||
| if (field.dynamic_rows_field) { | ||||||||||||
| // Look up from acc first (already processed), then fall back to the field's default | ||||||||||||
| const controlValue = parseInt( | ||||||||||||
| acc[field.dynamic_rows_field] | ||||||||||||
| ?? defaultsByHandle[field.dynamic_rows_field] | ||||||||||||
| ) || 0 | ||||||||||||
| rowCount = Math.max(controlValue, field.min_rows || 0) | ||||||||||||
| if (field.max_rows) { | ||||||||||||
| rowCount = Math.min(rowCount, field.max_rows) | ||||||||||||
| } | ||||||||||||
| } else { | ||||||||||||
|
Comment on lines
114
to
125
|
||||||||||||
| rowCount = field.fixed_rows || field.min_rows || 1 | ||||||||||||
| } | ||||||||||||
| // Initialize flat state with indexed keys for each row | ||||||||||||
| for (let i = 0; i < rowCount; i++) { | ||||||||||||
| field.grid_fields.forEach(nestedField => { | ||||||||||||
|
|
@@ -276,7 +295,7 @@ export default function formFields(fields, honeypot, hideFields, prepopulatedDat | |||||||||||
| this.submitFields[countKey] = currentCount + 1 | ||||||||||||
|
|
||||||||||||
| // Clone template row in DOM | ||||||||||||
| this.cloneGridRow(handle, currentCount) | ||||||||||||
| this.cloneGridRow(handle, currentCount, true) | ||||||||||||
| }, | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
|
|
@@ -293,28 +312,40 @@ export default function formFields(fields, honeypot, hideFields, prepopulatedDat | |||||||||||
| // Check min_rows limit | ||||||||||||
| if (field.min_rows && currentCount <= field.min_rows) return | ||||||||||||
|
|
||||||||||||
| // Remove row fields from state | ||||||||||||
| field.grid_fields.forEach(f => { | ||||||||||||
| delete this.submitFields[`${handle}.${index}.${f.handle}`] | ||||||||||||
| }) | ||||||||||||
| const gridId = this.formId ? `${this.formId}_${handle}` : handle | ||||||||||||
| const container = document.querySelector(`[data-grid-rows="${gridId}"]`) | ||||||||||||
| const row = container?.querySelector(`[data-grid-row="${index}"]`) | ||||||||||||
|
|
||||||||||||
| // Shift remaining rows down | ||||||||||||
| for (let i = index + 1; i < currentCount; i++) { | ||||||||||||
| const cleanup = () => { | ||||||||||||
| // Remove row fields from state | ||||||||||||
| field.grid_fields.forEach(f => { | ||||||||||||
| const oldKey = `${handle}.${i}.${f.handle}` | ||||||||||||
| const newKey = `${handle}.${i - 1}.${f.handle}` | ||||||||||||
| this.submitFields[newKey] = this.submitFields[oldKey] | ||||||||||||
| delete this.submitFields[oldKey] | ||||||||||||
| delete this.submitFields[`${handle}.${index}.${f.handle}`] | ||||||||||||
| }) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| this.submitFields[countKey] = currentCount - 1 | ||||||||||||
| // Shift remaining rows down | ||||||||||||
| for (let i = index + 1; i < currentCount; i++) { | ||||||||||||
| field.grid_fields.forEach(f => { | ||||||||||||
| const oldKey = `${handle}.${i}.${f.handle}` | ||||||||||||
| const newKey = `${handle}.${i - 1}.${f.handle}` | ||||||||||||
| this.submitFields[newKey] = this.submitFields[oldKey] | ||||||||||||
| delete this.submitFields[oldKey] | ||||||||||||
| }) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Shift errors in the parent formHandler component | ||||||||||||
| this.$dispatch('grid-row-removed', { handle, removedIndex: index }) | ||||||||||||
| this.submitFields[countKey] = currentCount - 1 | ||||||||||||
|
|
||||||||||||
| // Shift errors in the parent formHandler component | ||||||||||||
| this.$dispatch('grid-row-removed', { handle, removedIndex: index }) | ||||||||||||
|
|
||||||||||||
| // Rebuild all DOM rows so Alpine creates fresh, correct bindings | ||||||||||||
| this.rebuildGridRows(handle) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Rebuild all DOM rows so Alpine creates fresh, correct bindings | ||||||||||||
| this.rebuildGridRows(handle) | ||||||||||||
| if (row) { | ||||||||||||
| this.animateGridRowOut(row, cleanup) | ||||||||||||
| } else { | ||||||||||||
| cleanup() | ||||||||||||
| } | ||||||||||||
| }, | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
|
|
@@ -342,7 +373,7 @@ export default function formFields(fields, honeypot, hideFields, prepopulatedDat | |||||||||||
| /** | ||||||||||||
| * Clone template row in DOM and replace __INDEX__ placeholders. | ||||||||||||
| */ | ||||||||||||
| cloneGridRow(handle, index) { | ||||||||||||
| cloneGridRow(handle, index, animate = false) { | ||||||||||||
| const gridId = this.formId ? `${this.formId}_${handle}` : handle | ||||||||||||
| const template = document.querySelector(`[data-grid-template="${gridId}"]`) | ||||||||||||
| const container = document.querySelector(`[data-grid-rows="${gridId}"]`) | ||||||||||||
|
|
@@ -381,6 +412,10 @@ export default function formFields(fields, honeypot, hideFields, prepopulatedDat | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| row.setAttribute('data-grid-row', index) | ||||||||||||
| if (animate) { | ||||||||||||
| row.classList.add('ef-row-enter') | ||||||||||||
| row.addEventListener('animationend', () => row.classList.remove('ef-row-enter'), { once: true }) | ||||||||||||
| } | ||||||||||||
| container.appendChild(row) | ||||||||||||
| }, | ||||||||||||
|
|
||||||||||||
|
|
@@ -404,5 +439,104 @@ export default function formFields(fields, honeypot, hideFields, prepopulatedDat | |||||||||||
| this.cloneGridRow(handle, i) | ||||||||||||
| } | ||||||||||||
| }, | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Set the row count for a grid field, adding or removing rows as needed. | ||||||||||||
| */ | ||||||||||||
| setGridRowCount(handle, newCount) { | ||||||||||||
| const field = this.fieldsMap[handle] | ||||||||||||
| if (!field?.grid_fields) return | ||||||||||||
|
|
||||||||||||
| let count = Math.max(parseInt(newCount) || 0, field.min_rows || 0) | ||||||||||||
| if (field.max_rows) { | ||||||||||||
| count = Math.min(count, field.max_rows) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const countKey = `_grid_count_${handle}` | ||||||||||||
| const currentCount = this.submitFields[countKey] || 0 | ||||||||||||
| if (count === currentCount) return | ||||||||||||
|
|
||||||||||||
| const gridId = this.formId ? `${this.formId}_${handle}` : handle | ||||||||||||
| const container = document.querySelector(`[data-grid-rows="${gridId}"]`) | ||||||||||||
|
|
||||||||||||
| // Clean up any rows still animating out from a previous change | ||||||||||||
| if (container) { | ||||||||||||
| container.querySelectorAll('.ef-row-exit').forEach(row => row.remove()) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (count > currentCount) { | ||||||||||||
| for (let i = currentCount; i < count; i++) { | ||||||||||||
| field.grid_fields.forEach(f => { | ||||||||||||
| this.submitFields[`${handle}.${i}.${f.handle}`] = this.getFieldDefaultValue(f) | ||||||||||||
| }) | ||||||||||||
| this.cloneGridRow(handle, i, true) | ||||||||||||
| } | ||||||||||||
| } else { | ||||||||||||
| for (let i = currentCount - 1; i >= count; i--) { | ||||||||||||
| field.grid_fields.forEach(f => { | ||||||||||||
| delete this.submitFields[`${handle}.${i}.${f.handle}`] | ||||||||||||
| }) | ||||||||||||
| const row = container?.querySelector(`[data-grid-row="${i}"]`) | ||||||||||||
| if (row) { | ||||||||||||
| this.animateGridRowOut(row) | ||||||||||||
| } | ||||||||||||
| // Clear validation errors for removed rows | ||||||||||||
| this.$dispatch('grid-row-removed', { handle, removedIndex: i }) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
Comment on lines
474
to
486
|
||||||||||||
|
|
||||||||||||
| this.submitFields[countKey] = count | ||||||||||||
| }, | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Animate a grid row out: fade + translate, then collapse height. | ||||||||||||
| * Skips animation for prefers-reduced-motion. Includes safety timeout | ||||||||||||
| * in case animationend/transitionend never fires. | ||||||||||||
| */ | ||||||||||||
| animateGridRowOut(row, onComplete) { | ||||||||||||
| let done = false | ||||||||||||
| const finish = () => { | ||||||||||||
| if (done) return | ||||||||||||
| done = true | ||||||||||||
| row.remove() | ||||||||||||
| if (onComplete) onComplete() | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Skip animation when user prefers reduced motion | ||||||||||||
| if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { | ||||||||||||
| finish() | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const safetyTimeout = setTimeout(finish, 400) | ||||||||||||
|
|
||||||||||||
| row.classList.add('ef-row-exit') | ||||||||||||
| row.addEventListener('animationend', () => { | ||||||||||||
| // Phase 2: smoothly collapse the space | ||||||||||||
| row.style.height = row.offsetHeight + 'px' | ||||||||||||
| row.style.overflow = 'hidden' | ||||||||||||
| row.offsetHeight // force reflow | ||||||||||||
| row.style.transition = 'height .15s ease-in, margin .15s ease-in' | ||||||||||||
| row.style.height = '0' | ||||||||||||
| row.style.marginBottom = '0' | ||||||||||||
| row.addEventListener('transitionend', () => { | ||||||||||||
| clearTimeout(safetyTimeout) | ||||||||||||
| finish() | ||||||||||||
| }, { once: true }) | ||||||||||||
| }, { once: true }) | ||||||||||||
| }, | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Initialize dynamic grid rows that react to another field's value. | ||||||||||||
| */ | ||||||||||||
| initDynamicGridRows(handle, controlFieldHandle) { | ||||||||||||
| this.$watch( | ||||||||||||
| () => this.submitFields[controlFieldHandle], | ||||||||||||
| (newValue) => this.setGridRowCount(handle, newValue) | ||||||||||||
| ) | ||||||||||||
|
||||||||||||
| ) | |
| ) | |
| // Ensure the grid is initialized with the current controlling field value | |
| this.setGridRowCount(handle, this.submitFields[controlFieldHandle]) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,14 @@ | ||||||||||||||||||||||
| {{# Grid Field Template - Uses template cloning approach #}} | ||||||||||||||||||||||
| <style> | ||||||||||||||||||||||
| @keyframes ef-grid-row-in { from { opacity: 0; transform: translateY(-.5rem); } } | ||||||||||||||||||||||
| @keyframes ef-grid-row-out { to { opacity: 0; transform: translateY(-.5rem); } } | ||||||||||||||||||||||
| [data-grid-row].ef-row-enter { animation: ef-grid-row-in .2s ease-out; } | ||||||||||||||||||||||
| [data-grid-row].ef-row-exit { animation: ef-grid-row-out .15s ease-in forwards; pointer-events: none; } | ||||||||||||||||||||||
|
||||||||||||||||||||||
| [data-grid-row].ef-row-exit { animation: ef-grid-row-out .15s ease-in forwards; pointer-events: none; } | |
| [data-grid-row].ef-row-exit { animation: ef-grid-row-out .15s ease-in forwards; pointer-events: none; } | |
| @media (prefers-reduced-motion: reduce) { | |
| [data-grid-row].ef-row-enter, | |
| [data-grid-row].ef-row-exit { | |
| animation: none !important; | |
| transition: none !important; | |
| } | |
| } |
Copilot
AI
Feb 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inline <style> in the grid field template will be emitted once per grid instance, which can bloat HTML output and can break sites with strict CSP (no inline styles). Consider moving these keyframes/class rules into the package CSS (e.g., the compiled easy-forms.css) and only toggling classes from JS in the template.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
initializeFields()computes dynamic grid row count by callingfields.find(...)inside thereduce()loop. That makes initialization O(n^2) in the number of fields, which can become noticeable for larger forms. Consider precomputing a handle→field map (or handle→default map) once before the reduce and doing O(1) lookups instead.