Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dist/css/easy-forms.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/js/easy-forms.js

Large diffs are not rendered by default.

172 changes: 153 additions & 19 deletions resources/js/formFields.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Comment on lines 115 to 120
Copy link

Copilot AI Feb 8, 2026

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 calling fields.find(...) inside the reduce() 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.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dynamic grid initialization reads the controlling field value from the partially-built acc object. This makes rowCount dependent on field order: if the grid field appears before the controlling field in the blueprint, acc[field.dynamic_rows_field] will be undefined and the grid will initialize with the wrong number of rows. A more robust approach is a two-pass initialization (initialize non-grid fields first, then grids) or to resolve the controlling field’s default from fields instead of acc.

Copilot uses AI. Check for mistakes.
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 => {
Expand Down Expand Up @@ -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)
},

/**
Expand All @@ -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()
}
},

/**
Expand Down Expand Up @@ -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}"]`)
Expand Down Expand Up @@ -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)
},

Expand All @@ -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
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When setGridRowCount() reduces the number of rows, it deletes state and removes DOM nodes but it never dispatches the grid-row-removed event (used by the parent formHandler to drop/shift validation errors). This can leave stale error entries for removed rows, causing error summaries/scrolling to reference non-existent fields. Consider dispatching grid-row-removed for each removed index (or adding a dedicated event to clear errors for indices >= new count).

Copilot uses AI. Check for mistakes.

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)
)
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initDynamicGridRows() registers a watcher but never applies the current controlling field value immediately. If the controlling field is prepopulated (or already set) before this watcher is registered, the callback won’t run and the grid can render with an out-of-sync row count until the user changes the control field. Call setGridRowCount(handle, this.submitFields[controlFieldHandle]) once during initialization (in addition to the watcher) to ensure correct initial state.

Suggested change
)
)
// Ensure the grid is initialized with the current controlling field value
this.setGridRowCount(handle, this.submitFields[controlFieldHandle])

Copilot uses AI. Check for mistakes.

// Sync immediately in case prepopulated data changed the controlling field
this.setGridRowCount(handle, this.submitFields[controlFieldHandle])
},
}
}
18 changes: 18 additions & 0 deletions resources/views/form/fieldtypes/_grid.antlers.html
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; }
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Row enter/exit animations don’t fully respect prefers-reduced-motion: animateGridRowOut() skips animation when reduced motion is enabled, but the CSS-driven .ef-row-enter animation will still run. Consider adding a @media (prefers-reduced-motion: reduce) override to disable both .ef-row-enter/.ef-row-exit animations (and any transitions) for users who request reduced motion.

Suggested change
[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 uses AI. Check for mistakes.
@media (prefers-reduced-motion: reduce) {
[data-grid-row].ef-row-enter,
[data-grid-row].ef-row-exit { animation: none; transition: none; }
}
</style>
Comment on lines 2 to 11
Copy link

Copilot AI Feb 7, 2026

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.

Copilot uses AI. Check for mistakes.
<fieldset class="ef-grid mt-6" aria-label="{{ display | trans }}">
<legend class="sr-only">{{ display | trans }}</legend>

Expand Down Expand Up @@ -35,10 +45,18 @@
<div
data-grid-rows="{{ form_id }}_{{ handle }}"
x-init="
{{ if dynamic_rows_field }}
const dynamicValue = parseInt(submitFields['{{ dynamic_rows_field }}']) || 0;
const rowCount = submitFields['_grid_count_{{ handle }}'] || Math.max(dynamicValue, {{ min_rows ?? 0 }});
{{ else }}
const rowCount = submitFields['_grid_count_{{ handle }}'] || {{ fixed_rows ?? min_rows ?? 1 }};
{{ /if }}
for (let i = 0; i < rowCount; i++) {
cloneGridRow('{{ handle }}', i);
}
{{ if dynamic_rows_field }}
initDynamicGridRows('{{ handle }}', '{{ dynamic_rows_field }}');
{{ /if }}
"
></div>

Expand Down
8 changes: 8 additions & 0 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,14 @@ public function addConfigOptions()
'display' => 'Fixed rows',
'instructions' => 'Render exactly this many rows with no add/remove buttons. Overrides min/max.',
],
'dynamic_rows_field' => [
'type' => 'text',
'display' => 'Dynamic rows field',
'instructions' => 'Handle of a field whose value controls the number of grid rows. The grid will behave as fixed (no add/remove buttons).',
'unless' => [
'fixed_rows' => 'not null',
],
],
]);
}
}
23 changes: 8 additions & 15 deletions src/Tags/Concerns/HandlesFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

trait HandlesFields
{
protected array $gridRowOverrides = [];

protected static ?\Illuminate\Support\Collection $commonFieldOptionsCache = null;

private const REQUIRED_VALIDATION_KEYS = [
Expand Down Expand Up @@ -36,13 +34,6 @@ protected function getCachedCommonFieldOptions(): \Illuminate\Support\Collection
return self::$commonFieldOptionsCache;
}

/**
* Set grid row count overrides from the grid_rows tag parameter.
*/
protected function setGridRowOverrides(array $overrides): void
{
$this->gridRowOverrides = $overrides;
}
/**
* Process a field to extract needed properties and add optional flag.
*
Expand Down Expand Up @@ -92,14 +83,16 @@ protected function processField($field, ?string $parentHandle = null): array
$fieldData['min_rows'] = $fieldData['min_rows'] ?? 1;
$fieldData['max_rows'] = $fieldData['max_rows'] ?? null;
$fieldData['fixed_rows'] = $fieldData['fixed_rows'] ?? null;
$fieldData['is_fixed'] = ! empty($fieldData['fixed_rows']);
$fieldData['add_row_text'] = $fieldData['add_row'] ?? __('Add Row');
$fieldData['dynamic_rows_field'] = $fieldData['dynamic_rows_field'] ?? null;

// Apply grid_rows tag parameter override
if (isset($this->gridRowOverrides[$field->handle()])) {
$fieldData['fixed_rows'] = (int) $this->gridRowOverrides[$field->handle()];
$fieldData['is_fixed'] = true;
// fixed_rows takes precedence over dynamic_rows_field
if (! empty($fieldData['fixed_rows'])) {
$fieldData['dynamic_rows_field'] = null;
}

$fieldData['is_fixed'] = ! empty($fieldData['fixed_rows']) || ! empty($fieldData['dynamic_rows_field']);
$fieldData['add_row_text'] = $fieldData['add_row'] ?? __('Add Row');

}

// Add parent handle for nested fields (used in templates for name prefixing)
Expand Down
5 changes: 0 additions & 5 deletions src/Tags/EasyForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ public function index(): string
$form = $this->getForm();
$blueprint = $form->blueprint();

$gridRows = $this->params->get('grid_rows', []);
if (! empty($gridRows) && is_array($gridRows)) {
$this->setGridRowOverrides($gridRows);
}

$sectionsData = $this->processSections($blueprint);

// If we have sections, collect fields from them instead of reprocessing
Expand Down
Loading