Feature 326 Interaction

Right-click Context Menu เมนูคลิกขวา

A custom right-click menu on notes: rename, colour, pin, duplicate and delete, all saved in SQL Server.

Live demo

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

Right-click a note (or long-press on a touch screen, or focus it and press Shift+F10). Right-click the empty area for more.

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.

  • Changes will be listed here.

What to notice

  • contextmenu event + preventDefault() replaces the browser menu.
  • The menu is kept inside the window and works with the keyboard (Shift + F10).
  • Long-press opens it on touch screens.

Try it

Source code

7 parts, read from the files in this project
src/main/java/com/study/uiux/web/BoardApi.java The context menu calls PATCH, POST .../duplicate and DELETE
@PatchMapping("/notes/{id}")
public Note patchNote(@PathVariable int id, @Valid @RequestBody NotePatch patch) {
    return notes.patch(id, patch.title(), patch.color(), patch.pinned());
}

@PostMapping("/notes/{id}/duplicate")
@ResponseStatus(HttpStatus.CREATED)
public Note duplicateNote(@PathVariable int id) {
    return notes.duplicate(id);
}

@DeleteMapping("/notes/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteNote(@PathVariable int id) {
    notes.delete(id);
}
src/main/java/com/study/uiux/service/NoteService.java Each context-menu item is one small UPDATE
@Transactional
public Note patch(int id, String title, String color, Boolean pinned) {
    Note note = get(id);
    if (title != null) note.setTitle(title.strip());
    if (color != null) note.setColor(color);
    if (pinned != null) note.setPinned(pinned);
    return note; // dirty checking writes the UPDATE when the transaction commits
}

@Transactional
public Note duplicate(int id) {
    Note source = get(id);
    String title = source.getTitle().length() > 93 ? source.getTitle().substring(0, 93) : source.getTitle();
    return repository.save(new Note(title + " (copy)", source.getBody(), source.getColor(), false));
}

@Transactional
public void delete(int id) {
    repository.delete(get(id));
}
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/static/css/app.css Notes and the context menu
.notes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr)); gap: .9rem; }
.note {
  --nc: #fef3c7; --nt: #713f12; background: var(--nc); color: var(--nt); border-radius: var(--radius); padding: .9rem 1rem; min-height: 8.5rem;
  display: flex; flex-direction: column; gap: .35rem; position: relative; box-shadow: 0 1px 2px rgb(0 0 0 / .08), 0 8px 18px -12px rgb(0 0 0 / .35);
  cursor: context-menu; user-select: none; -webkit-touch-callout: none; transition: transform .15s;
}
.note:hover { transform: rotate(-.6deg) translateY(-2px); }
.note.green { --nc: #dcfce7; --nt: #14532d; } .note.blue { --nc: #dbeafe; --nt: #1e3a8a; } .note.pink { --nc: #fce7f3; --nt: #831843; }
.note.purple { --nc: #ede9fe; --nt: #4c1d95; } .note.gray { --nc: #e5e7eb; --nt: #1f2937; }
html[data-theme="dark"] .note { --nc: #4a3a12; --nt: #fde68a; }
html[data-theme="dark"] .note.green { --nc: #16361f; --nt: #bbf7d0; } html[data-theme="dark"] .note.blue { --nc: #172a4f; --nt: #bfdbfe; }
html[data-theme="dark"] .note.pink { --nc: #4a1a35; --nt: #fbcfe8; } html[data-theme="dark"] .note.purple { --nc: #2e2257; --nt: #ddd6fe; }
html[data-theme="dark"] .note.gray { --nc: #2a3242; --nt: #e5e7eb; }
.note h4 { margin: 0; font-size: .95rem; padding-right: 1.4rem; word-break: break-word; }
.note p { margin: 0; font-size: .82rem; opacity: .85; word-break: break-word; }
.note .pin { position: absolute; top: .7rem; right: .7rem; opacity: .8; }
.note:focus-visible { outline: 3px solid var(--primary); outline-offset: 3px; }
.note.flash { animation: flash .6s; }
@keyframes flash { 50% { transform: scale(1.04); } }
.context-menu {
  position: fixed; z-index: 500; min-width: 12.5rem; padding: .35rem; border-radius: .75rem; background: var(--surface); border: 1px solid var(--border);
  box-shadow: var(--shadow-lg); animation: menu-in .12s ease; font-size: .86rem;
}
.context-menu [role="menuitem"] { display: flex; align-items: center; gap: .65rem; width: 100%; padding: .48rem .65rem; border: 0; border-radius: .5rem; background: none; color: var(--text); font: inherit; cursor: pointer; text-align: left; }
.context-menu [role="menuitem"]:hover, .context-menu [role="menuitem"]:focus { background: var(--primary-soft); color: var(--primary-text); outline: none; }
.context-menu [role="menuitem"].danger { color: var(--danger); }
.context-menu [role="menuitem"].danger:hover, .context-menu [role="menuitem"].danger:focus { background: var(--danger-soft); }
.context-menu [role="menuitem"] kbd { margin-left: auto; font-size: .65rem; }
.context-menu hr { border: 0; height: 1px; background: var(--border); margin: .3rem .2rem; }
.context-menu .menu-colors { display: flex; gap: .4rem; padding: .4rem .65rem .5rem; }
.context-menu .menu-colors button { width: 1.35rem; height: 1.35rem; border-radius: 50%; border: 2px solid transparent; cursor: pointer; background: var(--nc); padding: 0; }
.context-menu .menu-colors button:focus, .context-menu .menu-colors button[aria-pressed="true"] { border-color: var(--text); outline: none; }
.context-menu .menu-label { font-size: .7rem; color: var(--text-3); padding: .35rem .65rem 0; text-transform: uppercase; letter-spacing: .05em; font-weight: 700; }
@keyframes menu-in { from { opacity: 0; transform: scale(.96); } }
src/main/resources/static/js/core.js Build the menu, keep it on screen, arrow keys inside it
const contextMenu = (() => {
  let menu = null, restoreFocus = null;
  function close() {
    if (!menu) return;
    menu.remove(); menu = null;
    document.removeEventListener('pointerdown', outside, true);
    restoreFocus?.focus?.({ preventScroll: true });
  }
  function outside(e) { if (menu && !menu.contains(e.target)) close(); }
  function open(x, y, items, focusBack = document.activeElement) {
    close();
    restoreFocus = focusBack;
    menu = document.createElement('div');
    menu.className = 'context-menu';
    menu.setAttribute('role', 'menu');
    for (const item of items) {
      if (item === '-') { menu.appendChild(document.createElement('hr')); continue; }
      if (item.render) { menu.appendChild(item.render(close)); continue; }
      const b = document.createElement('button');
      b.type = 'button';
      b.setAttribute('role', 'menuitem');
      b.className = item.danger ? 'danger' : '';
      b.innerHTML = `${icon(item.icon || 'dot')}<span>${esc(item.label)}</span>${item.kbd ? `<kbd>${esc(item.kbd)}</kbd>` : ''}`;
      b.addEventListener('click', () => { close(); item.action(); });
      menu.appendChild(b);
    }
    document.body.appendChild(menu);
    const w = menu.offsetWidth, h = menu.offsetHeight;
    menu.style.left = Math.min(x, innerWidth - w - 8) + 'px';   // flip at the right edge
    menu.style.top = Math.min(y, innerHeight - h - 8) + 'px';   // and at the bottom edge
    const focusables = () => $$('[role="menuitem"], .menu-colors button', menu);
    focusables()[0]?.focus();
    menu.addEventListener('keydown', (e) => {
      const list = focusables(), i = list.indexOf(document.activeElement);
      if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
        e.preventDefault();
        list[(i + (e.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length].focus();
      } else if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); close(); }
    });
    setTimeout(() => document.addEventListener('pointerdown', outside, true));
    addEventListener('scroll', close, { once: true, passive: true });
    addEventListener('resize', close, { once: true });
  }
  return { open, close };
})();
src/main/resources/static/js/demos.js The menu items for one note; each one calls the API
function menuFor(note) {
  const id = note.dataset.id, title = $('h4', note).textContent, pinned = note.dataset.pinned === 'true';
  return [
    { label: 'Rename…', icon: 'edit', kbd: 'F2', action: async () => {
      const name = await UI.prompt({ title: 'Rename note', label: 'Title', value: title, validate: (v) => (!v ? 'Title is required.' : v.length > 100 ? 'At most 100 characters.' : '') });
      if (name === null) return;
      await api('/api/notes/' + id, { method: 'PATCH', body: { title: name } });
      log(`<b>PATCH</b> note ${id} title = "${esc(name)}"`); reload(id);
    } },
    { render: (close) => {
      const wrap = document.createElement('div');
      wrap.innerHTML = '<div class="menu-label">Colour</div><div class="menu-colors"></div>';
      colors.forEach((c) => {
        const b = document.createElement('button');
        b.type = 'button'; b.className = 'note ' + c; b.setAttribute('aria-label', c); b.setAttribute('aria-pressed', String(note.dataset.color === c));
        b.style.cssText = 'min-height:0;padding:0;box-shadow:none';
        b.addEventListener('click', async () => { close(); await api('/api/notes/' + id, { method: 'PATCH', body: { color: c } }); log(`<b>PATCH</b> note ${id} color = ${c}`); reload(id); });
        $('.menu-colors', wrap).appendChild(b);
      });
      return wrap;
    } },
    '-',
    { label: pinned ? 'Unpin' : 'Pin to top', icon: 'pin', action: async () => { await api('/api/notes/' + id, { method: 'PATCH', body: { pinned: !pinned } }); log(`<b>PATCH</b> note ${id} pinned = ${!pinned}`); reload(id); } },
    { label: 'Duplicate', icon: 'copy', kbd: 'Ctrl D', action: async () => { const n = await api(`/api/notes/${id}/duplicate`, { method: 'POST' }); log(`<b>POST</b> duplicate → new note ${n.id}`); reload(n.id); } },
    '-',
    { label: 'Delete…', icon: 'trash', danger: true, kbd: 'Del', action: async () => {
      if (!(await UI.confirm({ title: 'Delete this note?', message: `"${title}" will be deleted.` }))) return;
      await api('/api/notes/' + id, { method: 'DELETE' }); log(`<b>DELETE</b> note ${id}`); reload();
    } }
  ];
}
src/main/resources/static/js/demos.js Open on right-click, on long-press and on Shift+F10 / the Menu key
box.addEventListener('contextmenu', (e) => {
  e.preventDefault();
  const note = e.target.closest('.note');
  UI.contextMenu.open(e.clientX, e.clientY, note ? menuFor(note) : areaMenu(), note || box);
});
let pressTimer = null;
box.addEventListener('pointerdown', (e) => {
  if (e.pointerType !== 'touch') return;
  const note = e.target.closest('.note');
  pressTimer = setTimeout(() => { if (note) UI.contextMenu.open(e.clientX, e.clientY, menuFor(note), note); }, 550);
});
['pointerup', 'pointermove', 'pointercancel'].forEach((t) => box.addEventListener(t, () => clearTimeout(pressTimer)));
box.addEventListener('keydown', (e) => {
  const note = e.target.closest('.note');
  if (!note) return;
  if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') {
    e.preventDefault();
    const r = note.getBoundingClientRect();
    UI.contextMenu.open(r.left + 20, r.top + 30, menuFor(note), note);
  } else if (e.key === 'F2') { e.preventDefault(); menuFor(note)[0].action(); }
  else if (e.key === 'Delete') { e.preventDefault(); menuFor(note)[6].action(); }
  else if (e.ctrlKey && e.key.toLowerCase() === 'd') { e.preventDefault(); menuFor(note)[4].action(); }
});

Are you sure?

Rename

↑↓ moveEnter openEsc close