Skip to content

待確認問題 #107

@wendyyuchensun

Description

@wendyyuchensun
  1. should modals be <form>?
Details ⏺ Recommendation: wrap the inputs in a .

For an input-heavy dialog like this (two text inputs + two radio groups), a improves accessibility in several concrete ways:

  1. Enter-to-submit — Keyboard and screen-reader users expect Enter in a text field to submit the dialog. Without a , that key does nothing here, so users must
    Tab to the confirm button. This is the biggest a11y win.
  2. Implicit submission semantics — A paired with a tells assistive tech "this button completes the form," which is stronger than a
    generic click handler.
  3. Native validation hooks — If you later need required, maxLength, or pattern (e.g., on fileName), the form will surface those messages through the browser's
    accessible validation UI for free.
  4. Landmark/region grouping — Some screen readers expose forms in their element lists, helping orientation in a dense dialog.

Caveat — the submit button lives in BasicModal. A clean wiring is:

<BasicModal ... onConfirm={onConfirm} confirmButtonProps={{ type: 'submit', form: 'setting-form' }}>
<form id="setting-form" onSubmit={(e) => { e.preventDefault(); onConfirm(); }} className="flex flex-col gap-6">
...inputs


The form attribute on the button lets it submit a form it isn't nested inside, which avoids restructuring BasicModal. If BasicModal doesn't forward extra props to its
confirm button, the lower-effort alternative is keeping onConfirm as-is and adding an invisible inside the form so Enter still
submits — uglier but functional.

Don't remove a form if one exists; don't leave the inputs unwrapped if there isn't one. For this component, adding the form is the more accessible choice.

✻ Churned for 20s

❯ But I don't need to submit anything for this form?

⏺ "Submit" in HTML-form terms isn't about HTTP — it's about completing the form's purpose. Your onConfirm already calls onSubmit(localConfig, exportType, fileName),
which is a submission: the user fills in a title, file name, export type, and color, then commits those values. That's exactly what a form models.

The a11y value of

here has nothing to do with sending data over the network. It's:

  • Enter key in either text input commits the dialog — without , Enter is dead.
  • Screen readers announce "form" and group the inputs as a related set.
  • The confirm button becomes type="submit", which is semantically "finish this task," not a generic click target.

So the question isn't "do I submit data somewhere?" — you do, to a parent callback. The question is "do users fill out fields and then commit them as a unit?" Yes,
they do. That's a form, even if nothing leaves the browser.

The only case where I'd skip the form is if the modal had zero text inputs (e.g., only radios/toggles applied instantly on change). With two TextInputs here, the
Enter-to-submit affordance alone is worth it.

✻ Sautéed for 13s

❯ How about the @src/components/iframe-input-modal.js ? Do you recommend to use a form too?
⎿  Read src/components/iframe-input-modal.js (90 lines)

⏺ Yes — even stronger case here than the settings modal.

This component is textbook form territory:

  1. Two required fields with validation logic — your handleConfirm is already a form-submit handler in disguise (check required, check format, bail, otherwise commit).
    Wrapping in makes that explicit.
  2. type="url" — pairing it with a real lets the browser participate (URL parsing, the native invalid-input pseudo-class, etc.) if you ever want it. Right now
    it's just a hint.
  3. Errors per field — screen readers already get the per-input error via aria-describedby (assuming TextInput wires that up). A form gives users a clear "this is a
    unit you complete and commit," which matches the mental model of fixing errors then trying again.
  4. Enter-to-submit — same point as before, but stronger here: a tiny two-field URL/title dialog is exactly the kind of modal where users expect to type title → Tab →
    type URL → Enter. Forcing them to mouse/Tab to a confirm button is friction.

Suggested wiring:

<BasicModal ... confirmButtonProps={{ type: 'submit', form: 'iframe-form' }}>
<form
id="iframe-form"
noValidate
onSubmit={(e) => { e.preventDefault(); handleConfirm(); }}
className="flex flex-col gap-6"
>
...inputs


noValidate is important — you already have nicer custom error messages, so suppress the browser's default validation bubbles while keeping the form semantics.

Bonus a11y consideration: when handleConfirm sets errors, focus should move to the first invalid field (or the error should be announced via an aria-live region).
That's independent of the form decision but worth doing alongside it — fields-with-errors that don't receive focus are a common WCAG 3.3.1 / 3.3.3 gap.

⏺ Yes, it's load-bearing here — without it, Enter inside the form does nothing.

Why: HTML's implicit submission (press Enter in a text field → submit) only works when the form has either:

  1. A single text input, or
  2. A submit button somewhere inside the form.

This modal has two text inputs (title + file name), so it falls outside rule 1. And BasicModal's confirm button is rendered outside the

(inside the modal
footer), so it can't act as the form's submit button either. The hidden is what teaches the form "Enter means submit."

If you'd rather avoid the hidden button, the cleaner alternative is to modify BasicModal so its confirm button can be wired to an external form:

// in BasicModal — add a prop
confirmType = 'button',
confirmForm, // optional id of an external form
...
<Button type={confirmType} form={confirmForm} ...>

Then in SettingModal:

<BasicModal ... confirmType="submit" confirmForm="setting-form">
<form id="setting-form" noValidate onSubmit={onFormSubmit} ...>

That's a broader change (touches a shared component) but removes the hidden button trick and makes the visible confirm button semantically type="submit" — strongest
a11y outcome.

Which path do you prefer?

Metadata

Metadata

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions