Right-click me
Open the context menu to rename, recolour, pin, duplicate or delete this note.
The native <dialog> element with a form; focus is trapped inside, Esc closes it and errors come from Bean Validation.
Open the context menu to rename, recolour, pin, duplicate or delete this note.
Focus a note with Tab and press Shift + F10 or the Menu key.
On a phone, press and hold a note to open the menu.
Every change is an UPDATE on the note table.
Coffee beans, oat milk, batteries.
Command palette, undo toast, sticky table header.
@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));
}
@PostMapping("/notes")
@ResponseStatus(HttpStatus.CREATED)
public Note createNote(@Valid @RequestBody NoteForm form) {
return notes.create(form.title(), form.body(), form.color());
}
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) {
}
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'))
);
<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>
.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; } }
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); }
};
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 });
}
});