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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user