From 44a357dfc4b743e2cd62feb6549f34277c148a16 Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 24 Mar 2026 21:53:57 +0100 Subject: [PATCH] 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) --- modules/bookmarks/i18n/default_de.json | 26 +++- modules/bookmarks/i18n/default_en.json | 26 +++- .../templates/bookmark-dialogs.phtml | 71 ++++++++--- .../web/css/components/app-bookmark-form.css | 67 ++++++++++- .../web/js/components/app-bookmark-save.js | 113 +++++++++++++++--- 5 files changed, 264 insertions(+), 39 deletions(-) diff --git a/modules/bookmarks/i18n/default_de.json b/modules/bookmarks/i18n/default_de.json index 5f97e3a..1d1f067 100644 --- a/modules/bookmarks/i18n/default_de.json +++ b/modules/bookmarks/i18n/default_de.json @@ -39,5 +39,29 @@ "Edit group": "Gruppe bearbeiten", "Group updated": "Gruppe aktualisiert", "Group name is required": "Gruppenname ist erforderlich", - "Maximum groups reached": "Maximale Anzahl an Gruppen erreicht" + "Maximum groups reached": "Maximale Anzahl an Gruppen erreicht", + "Add bookmark": "Lesezeichen hinzufügen", + "Saving...": "Speichern...", + "Create group": "Gruppe erstellen", + "Bookmarking this page:": "Lesezeichen für diese Seite:", + "Icon: Home": "Zuhause", + "Icon: People": "Personen", + "Icon: Person": "Person", + "Icon: Folder": "Ordner", + "Icon: Document": "Dokument", + "Icon: Settings": "Einstellungen", + "Icon: Security": "Sicherheit", + "Icon: Chart": "Diagramm", + "Icon: Mail": "E-Mail", + "Icon: Calendar": "Kalender", + "Icon: Clock": "Uhr", + "Icon: Star": "Stern", + "Icon: Heart": "Herz", + "Icon: Flag": "Flagge", + "Icon: Bookmark": "Lesezeichen", + "Icon: Location": "Standort", + "Icon: Lightning": "Blitz", + "Icon: Database": "Datenbank", + "Icon: Globe": "Globus", + "Icon: Code": "Code" } diff --git a/modules/bookmarks/i18n/default_en.json b/modules/bookmarks/i18n/default_en.json index 5928564..254ee1d 100644 --- a/modules/bookmarks/i18n/default_en.json +++ b/modules/bookmarks/i18n/default_en.json @@ -39,5 +39,29 @@ "Edit group": "Edit group", "Group updated": "Group updated", "Group name is required": "Group name is required", - "Maximum groups reached": "Maximum groups reached" + "Maximum groups reached": "Maximum groups reached", + "Add bookmark": "Add bookmark", + "Saving...": "Saving...", + "Create group": "Create group", + "Bookmarking this page:": "Bookmarking this page:", + "Icon: Home": "Home", + "Icon: People": "People", + "Icon: Person": "Person", + "Icon: Folder": "Folder", + "Icon: Document": "Document", + "Icon: Settings": "Settings", + "Icon: Security": "Security", + "Icon: Chart": "Chart", + "Icon: Mail": "Mail", + "Icon: Calendar": "Calendar", + "Icon: Clock": "Clock", + "Icon: Star": "Star", + "Icon: Heart": "Heart", + "Icon: Flag": "Flag", + "Icon: Bookmark": "Bookmark", + "Icon: Location": "Location", + "Icon: Lightning": "Lightning", + "Icon: Database": "Database", + "Icon: Globe": "Globe", + "Icon: Code": "Code" } diff --git a/modules/bookmarks/templates/bookmark-dialogs.phtml b/modules/bookmarks/templates/bookmark-dialogs.phtml index 70a64ef..e1a06cb 100644 --- a/modules/bookmarks/templates/bookmark-dialogs.phtml +++ b/modules/bookmarks/templates/bookmark-dialogs.phtml @@ -6,15 +6,40 @@ $layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; $bookmarkNav = is_array($layoutNav['bookmarks.nav'] ?? null) ? $layoutNav['bookmarks.nav'] : []; $bookmarkData = is_array($bookmarkNav['grouped'] ?? null) ? $bookmarkNav['grouped'] : []; $bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : []; + +// Human-readable icon labels — keep in sync with BookmarkService::ALLOWED_GROUP_ICONS +$iconLabels = [ + 'bi-house' => t('Icon: Home'), + 'bi-people' => t('Icon: People'), + 'bi-person' => t('Icon: Person'), + 'bi-folder' => t('Icon: Folder'), + 'bi-file-text' => t('Icon: Document'), + 'bi-gear' => t('Icon: Settings'), + 'bi-shield-lock' => t('Icon: Security'), + 'bi-bar-chart' => t('Icon: Chart'), + 'bi-envelope' => t('Icon: Mail'), + 'bi-calendar' => t('Icon: Calendar'), + 'bi-clock' => t('Icon: Clock'), + 'bi-star' => t('Icon: Star'), + 'bi-heart' => t('Icon: Heart'), + 'bi-flag' => t('Icon: Flag'), + 'bi-bookmark' => t('Icon: Bookmark'), + 'bi-pin-map' => t('Icon: Location'), + 'bi-lightning' => t('Icon: Lightning'), + 'bi-database' => t('Icon: Database'), + 'bi-globe' => t('Icon: Globe'), + 'bi-code-slash' => t('Icon: Code'), +]; ?>
-

+

+ - + placeholder="" + aria-describedby="bookmark-name-error"> + +
+ + +
-
- +
@@ -60,8 +91,9 @@ $bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['gro data-app-bookmark-group-dialog data-title-create="" data-title-edit="" - data-label-save="" - data-label-update="" + data-label-save="" + data-label-update="" + data-label-saving="" data-max-message="" data-msg-created="" data-msg-updated="" @@ -77,13 +109,18 @@ $bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['gro
+ placeholder="" + aria-describedby="bookmark-group-name-error"> +
- + @@ -91,7 +128,7 @@ $bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['gro diff --git a/modules/bookmarks/web/css/components/app-bookmark-form.css b/modules/bookmarks/web/css/components/app-bookmark-form.css index 1ac51d3..cbcefd3 100644 --- a/modules/bookmarks/web/css/components/app-bookmark-form.css +++ b/modules/bookmarks/web/css/components/app-bookmark-form.css @@ -56,6 +56,68 @@ padding-top: var(--app-spacing); } + /* URL preview */ + .app-bookmark-url-preview { + color: var(--app-muted-color); + font-size: 0.8rem; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + margin-bottom: 0.25rem; + } + + /* Inline field errors */ + .app-bookmark-field-error { + color: var(--app-action-danger-background); + font-size: 0.8rem; + line-height: 1.3; + min-height: 0; + } + + .app-bookmark-field-error:empty { + display: none; + } + + dialog[data-app-bookmark-dialog] form input[aria-invalid="true"], + dialog[data-app-bookmark-group-dialog] form input[aria-invalid="true"] { + border-color: var(--app-action-danger-background); + } + + /* Group label row with inline new-group link */ + .app-bookmark-group-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + } + + .app-bookmark-new-group-link { + all: unset; + cursor: pointer; + font-size: 0.8rem; + color: var(--app-primary); + display: inline-flex; + align-items: center; + gap: 0.15rem; + } + + .app-bookmark-new-group-link:hover { + text-decoration: underline; + } + + .app-bookmark-new-group-link:focus-visible { + outline: 2px solid var(--app-primary); + outline-offset: 2px; + border-radius: 2px; + } + + .app-bookmark-new-group-link > i { + font-size: 0.85rem; + } + + /* Delete button */ .app-bookmark-delete-btn { color: var(--app-action-danger-background); border-color: var(--app-action-danger-background); @@ -67,10 +129,7 @@ color: var(--app-action-danger-color); } - .app-bookmark-new-group-btn { - width: 100%; - } - + /* Icon picker */ .app-bookmark-group-icon-picker { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); diff --git a/modules/bookmarks/web/js/components/app-bookmark-save.js b/modules/bookmarks/web/js/components/app-bookmark-save.js index 878a7ed..fc75506 100644 --- a/modules/bookmarks/web/js/components/app-bookmark-save.js +++ b/modules/bookmarks/web/js/components/app-bookmark-save.js @@ -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';