feat: improve bookmark dialog UX with distinct modes, inline validation, and accessibility

Refine bookmark create/edit and group create/edit dialogs to enterprise
quality: distinct titles per mode (Add bookmark vs Edit bookmark), URL
preview when creating, inline field validation with aria-invalid instead
of toast-only errors, loading spinner on submit, compact inline
new-group link, human-readable icon picker aria-labels, and aria-pressed
on selected icons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 21:53:57 +01:00
parent 211149b3f6
commit 44a357dfc4
5 changed files with 264 additions and 39 deletions

View File

@@ -53,12 +53,16 @@ export function initBookmarkSave(root = document, options = {}) {
const closeBookmarkBtns = bookmarkDialog.querySelectorAll('[data-bookmark-dialog-close]');
const submitBtn = bookmarkDialog.querySelector('[data-bookmark-submit-label]');
const titleEl = bookmarkDialog.querySelector('#app-bookmark-dialog-title');
const urlPreviewEl = bookmarkDialog.querySelector('[data-bookmark-url-preview]');
const nameErrorEl = bookmarkForm.querySelector('[data-field-error="name"]');
const maxBookmarkMessage = bookmarkDialog.dataset.maxMessage || '';
const bookmarkMessageSaved = bookmarkDialog.dataset.msgSaved || 'Bookmark saved';
const bookmarkMessageUpdated = bookmarkDialog.dataset.msgUpdated || 'Bookmark updated';
const bookmarkMessageErrorGeneric = bookmarkDialog.dataset.msgErrorGeneric || 'Bookmark action failed';
const bookmarkMessageNameRequired = bookmarkDialog.dataset.msgNameRequired || 'Bookmark name is required';
const labelSaving = bookmarkDialog.dataset.labelSaving || 'Saving...';
const urlPreviewLabel = bookmarkDialog.dataset.urlPreviewLabel || 'Bookmarking this page:';
const bookmarkApiErrorMessages = {
name_required: bookmarkDialog.dataset.msgNameRequired || bookmarkMessageErrorGeneric,
url_required: bookmarkDialog.dataset.msgUrlRequired || bookmarkMessageErrorGeneric,
@@ -70,11 +74,55 @@ export function initBookmarkSave(root = document, options = {}) {
let editingBookmarkId = null;
let bookmarkReturnState = null;
let savedSubmitLabel = '';
let groupEditingId = null;
let groupMode = 'create';
let groupSource = 'sidebar';
// --- Inline validation helpers ---
const showFieldError = (input, errorEl, message) => {
if (errorEl instanceof HTMLElement) {
errorEl.textContent = message;
}
if (input instanceof HTMLElement) {
input.setAttribute('aria-invalid', 'true');
}
};
const clearFieldError = (input, errorEl) => {
if (errorEl instanceof HTMLElement) {
errorEl.textContent = '';
}
if (input instanceof HTMLElement) {
input.removeAttribute('aria-invalid');
}
};
// Clear inline error when user starts typing
if (nameInput instanceof HTMLElement) {
nameInput.addEventListener('input', () => {
clearFieldError(nameInput, nameErrorEl);
}, { signal });
}
// --- URL preview ---
const updateUrlPreview = (show) => {
if (!(urlPreviewEl instanceof HTMLElement)) return;
if (show) {
const url = currentRelativeUrl();
urlPreviewEl.textContent = urlPreviewLabel + ' ' + (url || '/');
urlPreviewEl.hidden = false;
} else {
urlPreviewEl.hidden = true;
urlPreviewEl.textContent = '';
}
};
// --- Core helpers ---
const syncModalBodyClass = () => {
const isOpen = bookmarkDialog.open || (hasGroupDialog && groupDialog.open);
document.body.classList.toggle('modal-is-open', isOpen);
@@ -111,6 +159,7 @@ export function initBookmarkSave(root = document, options = {}) {
// no-op
}
bookmarkForm.removeAttribute('aria-busy');
clearFieldError(nameInput, nameErrorEl);
syncModalBodyClass();
};
@@ -127,7 +176,14 @@ export function initBookmarkSave(root = document, options = {}) {
const setBookmarkSubmitPending = (pending) => {
if (submitBtn instanceof HTMLButtonElement) {
submitBtn.disabled = pending;
if (pending) {
savedSubmitLabel = submitBtn.textContent || '';
submitBtn.textContent = labelSaving;
submitBtn.disabled = true;
} else {
submitBtn.textContent = savedSubmitLabel || submitBtn.textContent;
submitBtn.disabled = false;
}
}
bookmarkForm.setAttribute('aria-busy', pending ? 'true' : 'false');
};
@@ -142,15 +198,18 @@ export function initBookmarkSave(root = document, options = {}) {
const applyBookmarkModeUi = (isEditMode) => {
if (titleEl) {
titleEl.textContent = isEditMode
? (bookmarkDialog.dataset.titleEdit || 'Bookmarks')
: (bookmarkDialog.dataset.titleCreate || 'Bookmarks');
? (bookmarkDialog.dataset.titleEdit || 'Edit bookmark')
: (bookmarkDialog.dataset.titleCreate || 'Add bookmark');
}
setBookmarkSubmitLabel(isEditMode
? (bookmarkDialog.dataset.labelUpdate || 'Save')
: (bookmarkDialog.dataset.labelSave || 'Save'));
? (bookmarkDialog.dataset.labelUpdate || 'Save changes')
: (bookmarkDialog.dataset.labelSave || 'Add bookmark'));
updateUrlPreview(!isEditMode);
};
const configureBookmarkModeFromTopbar = () => {
clearFieldError(nameInput, nameErrorEl);
if (!(openBtn instanceof HTMLButtonElement)) {
applyBookmarkModeUi(false);
editingBookmarkId = null;
@@ -275,7 +334,8 @@ export function initBookmarkSave(root = document, options = {}) {
const name = nameInput instanceof HTMLInputElement ? nameInput.value.trim() : '';
if (!name) {
showAsyncFlash('warning', bookmarkMessageNameRequired);
showFieldError(nameInput, nameErrorEl, bookmarkMessageNameRequired);
if (nameInput instanceof HTMLInputElement) nameInput.focus();
return;
}
@@ -365,12 +425,14 @@ export function initBookmarkSave(root = document, options = {}) {
const groupCloseBtns = groupDialog.querySelectorAll('[data-bookmark-group-dialog-close]');
const groupTitleEl = groupDialog.querySelector('#app-bookmark-group-dialog-title');
const groupSubmitBtn = groupDialog.querySelector('[data-bookmark-group-submit-label]');
const groupNameErrorEl = groupForm.querySelector('[data-field-error="group-name"]');
const groupMessageCreated = groupDialog.dataset.msgCreated || 'Group created';
const groupMessageUpdated = groupDialog.dataset.msgUpdated || 'Group updated';
const groupMessageErrorGeneric = groupDialog.dataset.msgErrorGeneric || 'Group action failed';
const groupMessageNameRequired = groupDialog.dataset.msgNameRequired || 'Group name is required';
const maxGroupMessage = groupDialog.dataset.maxMessage || 'Maximum groups reached';
const groupLabelSaving = groupDialog.dataset.labelSaving || 'Saving...';
const groupApiErrorMessages = {
name_required: groupMessageNameRequired,
not_found: groupMessageErrorGeneric,
@@ -378,11 +440,20 @@ export function initBookmarkSave(root = document, options = {}) {
update_failed: groupMessageErrorGeneric
};
let savedGroupSubmitLabel = '';
const resolveGroupApiErrorMessage = (errorCode) => {
if (!errorCode) return groupMessageErrorGeneric;
return groupApiErrorMessages[errorCode] || groupMessageErrorGeneric;
};
// Clear group name inline error on input
if (groupNameInput instanceof HTMLElement) {
groupNameInput.addEventListener('input', () => {
clearFieldError(groupNameInput, groupNameErrorEl);
}, { signal });
}
const closeGroupDialog = () => {
try {
if (groupDialog.open) groupDialog.close();
@@ -390,6 +461,7 @@ export function initBookmarkSave(root = document, options = {}) {
// no-op
}
groupForm.removeAttribute('aria-busy');
clearFieldError(groupNameInput, groupNameErrorEl);
syncModalBodyClass();
};
@@ -406,7 +478,14 @@ export function initBookmarkSave(root = document, options = {}) {
const setGroupSubmitPending = (pending) => {
if (groupSubmitBtn instanceof HTMLButtonElement) {
groupSubmitBtn.disabled = pending;
if (pending) {
savedGroupSubmitLabel = groupSubmitBtn.textContent || '';
groupSubmitBtn.textContent = groupLabelSaving;
groupSubmitBtn.disabled = true;
} else {
groupSubmitBtn.textContent = savedGroupSubmitLabel || groupSubmitBtn.textContent;
groupSubmitBtn.disabled = false;
}
}
groupForm.setAttribute('aria-busy', pending ? 'true' : 'false');
};
@@ -424,11 +503,11 @@ export function initBookmarkSave(root = document, options = {}) {
groupIconInput.value = value;
}
if (groupIconPicker instanceof HTMLElement) {
groupIconPicker.querySelectorAll('.app-bookmark-icon-active').forEach((item) => item.classList.remove('app-bookmark-icon-active'));
const selected = groupIconPicker.querySelector(`[data-icon="${value}"]`);
if (selected instanceof HTMLElement) {
selected.classList.add('app-bookmark-icon-active');
}
groupIconPicker.querySelectorAll('[data-icon]').forEach((item) => {
const isActive = item.dataset.icon === value;
item.classList.toggle('app-bookmark-icon-active', isActive);
item.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
}
};
@@ -460,6 +539,7 @@ export function initBookmarkSave(root = document, options = {}) {
bookmarkReturnState = groupSource === 'bookmark' ? restoreState : null;
groupForm.reset();
clearFieldError(groupNameInput, groupNameErrorEl);
setGroupIcon(icon);
if (groupNameInput instanceof HTMLInputElement) {
groupNameInput.value = String(name || '');
@@ -471,8 +551,8 @@ export function initBookmarkSave(root = document, options = {}) {
: (groupDialog.dataset.titleCreate || 'New group');
}
setGroupSubmitLabel(groupMode === 'edit'
? (groupDialog.dataset.labelUpdate || 'Save')
: (groupDialog.dataset.labelSave || 'Save'));
? (groupDialog.dataset.labelUpdate || 'Save changes')
: (groupDialog.dataset.labelSave || 'Create group'));
};
const handleGroupCancel = () => {
@@ -498,7 +578,7 @@ export function initBookmarkSave(root = document, options = {}) {
}
};
if (newGroupBtn instanceof HTMLButtonElement) {
if (newGroupBtn instanceof HTMLElement) {
newGroupBtn.addEventListener('click', (event) => {
event.preventDefault();
openCreateGroupFromBookmarkDialog();
@@ -563,7 +643,8 @@ export function initBookmarkSave(root = document, options = {}) {
const name = groupNameInput instanceof HTMLInputElement ? groupNameInput.value.trim() : '';
if (!name) {
showAsyncFlash('warning', groupMessageNameRequired);
showFieldError(groupNameInput, groupNameErrorEl, groupMessageNameRequired);
if (groupNameInput instanceof HTMLInputElement) groupNameInput.focus();
return;
}
const icon = groupIconInput instanceof HTMLInputElement ? groupIconInput.value : 'bi-folder';