Feature 317 Overlays

Modal Dialog ป็อปอัพ

The native <dialog> element with a form; focus is trapped inside, Esc closes it and errors come from Bean Validation.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Dialogs

Notes in SQL Server 6

Right-click me

Open the context menu to rename, recolour, pin, duplicate or delete this note.

Keyboard

Focus a note with Tab and press Shift + F10 or the Menu key.

Touch

On a phone, press and hold a note to open the menu.

Saved in SQL Server

Every change is an UPDATE on the note table.

Shopping

Coffee beans, oat milk, batteries.

Ideas

Command palette, undo toast, sticky table header.

The header and footer of this dialog stay in place while only the body scrolls. That keeps the actions visible on small screens.

1. What you may do

Read, copy and change any part of this code to learn. Break things on purpose and see what happens.

2. Keyboard

Press Tab to move between the buttons: focus stays inside the dialog until it closes. Esc closes it.

3. Backdrop

Clicking the dark area outside the box also closes it. The page behind is inert, so screen readers ignore it.

4. Scrolling

The page behind does not scroll while the dialog is open in most browsers, because the dialog is in the top layer.

5. Focus return

When the dialog closes, focus goes back to the button that opened it, so keyboard users do not lose their place.

6. Sizes

The width is min(36rem, 100vw − 2rem), so it always fits a phone. The height is limited to 85vh.

Saved for this browser

A form with method="dialog" closes the dialog without any JavaScript.

What to notice

  • showModal() gives a backdrop, focus trap and Esc for free.
  • The form posts JSON; @Valid errors come back field by field.
  • Focus returns to the button that opened the dialog.

Try it

Source code

8 parts, read from the files in this project
src/main/java/com/study/uiux/web/ApiExceptionHandler.java Field errors from @Valid as JSON
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> invalid(MethodArgumentNotValidException e) {
    Map<String, String> errors = new LinkedHashMap<>();
    for (FieldError fe : e.getBindingResult().getFieldErrors()) errors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
    return ResponseEntity.badRequest().body(Map.of("message", "Please check the highlighted fields.", "errors", errors));
}
src/main/java/com/study/uiux/web/BoardApi.java POST /api/notes with @Valid
@PostMapping("/notes")
@ResponseStatus(HttpStatus.CREATED)
public Note createNote(@Valid @RequestBody NoteForm form) {
    return notes.create(form.title(), form.body(), form.color());
}
src/main/java/com/study/uiux/web/Dtos.java The note form: @Valid turns these rules into field errors
public record NoteForm(
        @NotBlank(message = "Title is required.") @Size(max = 100, message = "Title is at most 100 characters.") String title,
        @Size(max = 1000, message = "Text is at most 1000 characters.") String body,
        @NotBlank @Pattern(regexp = "yellow|green|blue|pink|purple|gray", message = "Pick one of the colours.") String color) {
}
src/main/resources/db/migration/V1__schema.sql note table: created in a modal, edited from the context menu
CREATE TABLE note (
    id         INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_note PRIMARY KEY,
    title      NVARCHAR(100)  NOT NULL,
    body       NVARCHAR(1000) NOT NULL CONSTRAINT df_note_body DEFAULT N'',
    color      NVARCHAR(10)   NOT NULL CONSTRAINT df_note_color DEFAULT N'yellow',
    pinned     BIT            NOT NULL CONSTRAINT df_note_pinned DEFAULT 0,
    created_at DATETIME2(0)   NOT NULL CONSTRAINT df_note_created DEFAULT SYSUTCDATETIME(),
    updated_at DATETIME2(0)   NOT NULL CONSTRAINT df_note_updated DEFAULT SYSUTCDATETIME(),
    CONSTRAINT ck_note_color CHECK (color IN (N'yellow', N'green', N'blue', N'pink', N'purple', N'gray'))
);
src/main/resources/templates/demos/l317.html The form dialog: method="dialog" is not used here because we POST JSON ourselves
<dialog class="modal" id="noteDialog" aria-labelledby="noteDialogTitle">
  <form id="noteForm" novalidate>
    <div class="modal-head">
      <h2 id="noteDialogTitle">New note</h2>
      <button class="icon-btn" type="button" data-close aria-label="Close"><svg class="i"><use href="#i-x"/></svg></button>
    </div>
    <div class="modal-body stack">
      <p class="muted small" style="margin:0">Leave the title empty and press Save to see the server's validation message.</p>
      <label class="field">Title <input class="input" name="title" maxlength="120" autocomplete="off"><span class="field-error" data-error="title"></span></label>
      <label class="field">Text <textarea class="textarea" name="body" rows="4"></textarea><span class="field-error" data-error="body"></span></label>
      <fieldset class="field" style="border:0;padding:0;margin:0">
        <legend style="margin-bottom:.4rem">Colour</legend>
        <div class="row" style="gap:.5rem">
          <label th:each="c : ${ {'yellow','green','blue','pink','purple','gray'} }" class="note" th:classappend="${c}"
                 style="min-height:0;padding:.45rem .7rem;cursor:pointer;flex-direction:row;align-items:center;gap:.35rem;font-size:.8rem">
            <input type="radio" name="color" th:value="${c}" th:checked="${c == 'yellow'}" class="check"><span th:text="${c}">c</span>
          </label>
        </div>
        <span class="field-error" data-error="color"></span>
      </fieldset>
    </div>
    <div class="modal-foot">
      <button class="btn btn-ghost" type="button" data-close>Cancel</button>
      <button class="btn btn-primary" type="submit" id="noteSave"><svg class="i"><use href="#i-check"/></svg>Save note</button>
    </div>
  </form>
</dialog>
src/main/resources/static/css/app.css Native <dialog> styled as a modal
.modal {
  border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; width: min(36rem, calc(100vw - 2rem)); max-height: min(85vh, 48rem);
  background: var(--surface); color: var(--text); box-shadow: var(--shadow-lg);
}
.modal[open] { animation: modal-in .25s var(--ease); display: flex; flex-direction: column; }
.modal::backdrop { background: rgb(2 6 23 / .5); backdrop-filter: blur(2px); animation: fade-in .2s; }
.modal > form, .modal > .modal-inner { display: flex; flex-direction: column; min-height: 0; flex: 1; }
.modal-sm { width: min(26rem, calc(100vw - 2rem)); }
.modal-sm form { padding: 1.5rem; }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); gap: 1rem; flex: none; }
.modal-head h2 { margin: 0; }
.modal-body { padding: 1.25rem; overflow-y: auto; flex: 1; min-height: 0; }
.modal-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1.25rem; flex-wrap: wrap; }
.modal-foot { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem 1.25rem; border-top: 1px solid var(--border); background: var(--surface-2); flex: none; flex-wrap: wrap; }
.modal-note { padding: 0 1.25rem 1rem; margin: 0; }
@keyframes modal-in { from { opacity: 0; transform: translateY(14px) scale(.97); } }
@keyframes fade-in { from { opacity: 0; } }
src/main/resources/static/js/core.js Open a <dialog>, close on backdrop click, give focus back
const modal = {
  open(dialog, opener = document.activeElement) {
    if (!dialog || dialog.open) return;
    dialog._opener = opener;
    dialog.showModal();
    if (!dialog._wired) {
      dialog._wired = true;
      dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close('backdrop'); });
      dialog.addEventListener('close', () => { dialog._opener?.focus?.({ preventScroll: true }); });
      $$('[data-close]', dialog).forEach((b) => b.addEventListener('click', () => dialog.close('cancel')));
    }
  },
  close(dialog, value) { if (dialog?.open) dialog.close(value); }
};
src/main/resources/static/js/demos.js Submit as JSON; show the server's field errors next to each field
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  clearErrors();
  const body = { title: form.title.value, body: form.body.value, color: form.color.value };
  try {
    const note = await UI.withButton($('#noteSave'), () => api('/api/notes', { method: 'POST', body }));
    dlg.close();
    $('#noteList').insertAdjacentHTML('afterbegin', noteHtml(note));
    $('#noteCount').textContent = $$('#noteList .note').length;
    toast({ type: 'success', title: 'Note created', message: `INSERT INTO note ... id ${note.id}` });
  } catch (err) {
    const errors = err.data?.errors || {};
    for (const [field, message] of Object.entries(errors)) {
      const slot = $(`[data-error="${field}"]`, form);
      if (slot) slot.textContent = message;
      form.elements[field]?.setAttribute?.('aria-invalid', 'true');
    }
    const first = Object.keys(errors)[0];
    if (first) form.elements[first]?.focus?.(); else toast({ type: 'error', title: 'Not saved', message: err.message });
  }
});

Are you sure?

Rename

↑↓ moveEnter openEsc close