@@ -235,15 +235,51 @@ function M:results()
235235 return out
236236end
237237
238- --- Submit the form: gather values, close windows, invoke `on_submit(results)`.
238+ --- Submit the form: gather values, run validators, and invoke
239+ --- `on_submit(results)` only if everything validates. If any input has an
240+ --- error, submission is blocked, all inputs are force-validated (so the user
241+ --- sees every error, including ones on untouched fields), and focus moves to
242+ --- the first invalid input.
239243function M :submit ()
244+ if self ._visible and self :_validate_all () then
245+ -- Validation failed — do not close, do not invoke on_submit.
246+ return
247+ end
240248 local results = self :results ()
241249 self :hide ()
242250 if self ._on_submit then
243251 self ._on_submit (results )
244252 end
245253end
246254
255+ --- Run every input's validator (marking each as touched first) and render
256+ --- results. Returns `true` if any input has an error.
257+ function M :_validate_all ()
258+ local any_error = false
259+ local first_bad = nil
260+ for i , input in ipairs (self ._inputs ) do
261+ if input .validator then
262+ input ._touched = true
263+ local err = input .validator (input :value ())
264+ if err == " " then
265+ err = nil
266+ end
267+ input ._error = err
268+ self :_render_validation (input )
269+ if err and not first_bad then
270+ first_bad = i
271+ end
272+ if err then
273+ any_error = true
274+ end
275+ end
276+ end
277+ if first_bad then
278+ self :_focus (first_bad )
279+ end
280+ return any_error
281+ end
282+
247283--- Cancel the form: close windows, invoke `on_cancel()` if provided.
248284function M :cancel ()
249285 self :hide ()
@@ -252,6 +288,112 @@ function M:cancel()
252288 end
253289end
254290
291+ --- Install validation autocmds for an input. No-op if the input has no
292+ --- validator. Validation runs:
293+ --- - on `WinLeave` (blurring the field marks it touched and validates)
294+ --- - on `TextChanged` / `TextChangedI` IF the input is already touched
295+ ---
296+ --- For `select` inputs, the user "touches" the field by picking an option;
297+ --- `_render_display`'s `nvim_buf_set_lines` call also fires `TextChanged`
298+ --- so the same path handles re-validation there.
299+ function M :_install_validation (input )
300+ if not input .validator then
301+ return
302+ end
303+ local buf = input .buf
304+ if not buf then
305+ return
306+ end
307+ local form = self
308+ local group = vim .api .nvim_create_augroup (" InputFormValidate_" .. tostring (buf ), { clear = true })
309+ input ._val_group = group
310+
311+ vim .api .nvim_create_autocmd (" WinLeave" , {
312+ group = group ,
313+ buffer = buf ,
314+ callback = function ()
315+ input ._touched = true
316+ form :_validate_input (input )
317+ end ,
318+ })
319+
320+ vim .api .nvim_create_autocmd ({ " TextChanged" , " TextChangedI" }, {
321+ group = group ,
322+ buffer = buf ,
323+ callback = function ()
324+ if input ._touched then
325+ form :_validate_input (input )
326+ end
327+ end ,
328+ })
329+
330+ -- For `select` inputs, changes happen while the dropdown buffer is current,
331+ -- so `TextChanged` on the display buffer doesn't fire. The input exposes an
332+ -- `_on_change` hook that we use to mark it touched and re-validate.
333+ input ._on_change = function ()
334+ input ._touched = true
335+ form :_validate_input (input )
336+ end
337+ end
338+
339+ --- Run the validator for one input and update its visual error state.
340+ function M :_validate_input (input )
341+ if not input .validator then
342+ return
343+ end
344+ local err = input .validator (input :value ())
345+ if err == " " then
346+ err = nil
347+ end
348+ input ._error = err
349+ self :_render_validation (input )
350+ end
351+
352+ --- Apply/clear the red border, title, and footer error message on an input's
353+ --- floating window based on its current `_error` state.
354+ function M :_render_validation (input )
355+ local win = input .win
356+ if not (win and vim .api .nvim_win_is_valid (win )) then
357+ return
358+ end
359+ local has_error = input ._error ~= nil
360+
361+ if has_error then
362+ vim .wo [win ].winhl = table.concat ({
363+ " NormalFloat:InputFormField" ,
364+ " FloatBorder:InputFormFieldErrorBorder" ,
365+ " FloatTitle:InputFormFieldErrorTitle" ,
366+ " FloatFooter:InputFormFieldError" ,
367+ }, " ," )
368+ else
369+ vim .wo [win ].winhl = table.concat ({
370+ " NormalFloat:InputFormField" ,
371+ " FloatBorder:InputFormFieldBorder" ,
372+ " FloatTitle:InputFormFieldTitle" ,
373+ }, " ," )
374+ end
375+
376+ -- Footer (error message) requires nvim 0.10+.
377+ if vim .fn .has (" nvim-0.10" ) == 1 then
378+ local ok , cfg = pcall (vim .api .nvim_win_get_config , win )
379+ if ok and cfg and cfg .relative ~= " " then
380+ if has_error then
381+ -- Truncate to window width with an ellipsis.
382+ local max_w = (cfg .width or 0 ) - 2
383+ local msg = input ._error
384+ if max_w > 3 and vim .fn .strdisplaywidth (msg ) > max_w then
385+ msg = vim .fn .strcharpart (msg , 0 , max_w - 1 ) .. " …"
386+ end
387+ cfg .footer = " " .. msg .. " "
388+ cfg .footer_pos = " left"
389+ else
390+ cfg .footer = " "
391+ end
392+ pcall (vim .api .nvim_win_set_config , win , cfg )
393+ end
394+ end
395+ end
396+
255397--- Build a help-line string describing the active keymaps.
256398function M :_help_line ()
257399 local km = config .options .keymaps
0 commit comments