diff --git a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts
index 91da233e2f43..7857e4233978 100644
--- a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts
+++ b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts
@@ -670,7 +670,31 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co
Underline,
TextAlign.configure({ types: ['heading', 'paragraph', 'listItem', 'dotImage'] }),
Highlight.configure({ HTMLAttributes: { style: 'background: #accef7;' } }),
- Link.configure({ autolink: false, openOnClick: false })
+ // Extends the default Link mark with accessibility attributes (title, aria-label)
+ // and rel. These are persisted in the TipTap JSON and rendered in the editor DOM.
+ Link.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ title: {
+ default: null,
+ parseHTML: (el) => el.getAttribute('title'),
+ renderHTML: (attrs) => (attrs.title ? { title: attrs.title } : {})
+ },
+ 'aria-label': {
+ default: null,
+ parseHTML: (el) => el.getAttribute('aria-label'),
+ renderHTML: (attrs) =>
+ attrs['aria-label'] ? { 'aria-label': attrs['aria-label'] } : {}
+ },
+ rel: {
+ default: null,
+ parseHTML: (el) => el.getAttribute('rel'),
+ renderHTML: (attrs) => (attrs.rel ? { rel: attrs.rel } : {})
+ }
+ };
+ }
+ }).configure({ autolink: false, openOnClick: false })
];
}
diff --git a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.css b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.css
index 86feaa54d713..81133f06fac4 100644
--- a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.css
+++ b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.css
@@ -57,27 +57,39 @@
}
.current-link-view__icon {
- @apply text-2xl;
+ @apply text-base text-gray-500;
}
.current-link-view__link {
- @apply overflow-hidden text-ellipsis whitespace-nowrap max-w-[18.75rem] inline-block;
+ @apply overflow-hidden text-ellipsis whitespace-nowrap max-w-[18.75rem] inline-block text-xs;
}
-.current-link-view__checkbox-container {
- @apply flex items-center gap-3 mb-6;
+.current-link-view__actions {
+ @apply flex gap-2 mt-4 pt-4 border-t border-gray-200;
}
-.current-link-view__checkbox {
- @apply mr-1;
+.field {
+ @apply flex flex-col gap-1 mb-3;
}
-.current-link-view__actions {
- @apply flex gap-3;
+.field label {
+ @apply text-xs font-medium text-gray-600;
+}
+
+.field input {
+ @apply h-8 text-sm;
+}
+
+.toggle-advanced {
+ @apply flex items-center gap-1 text-xs font-medium text-gray-500 bg-transparent border-none cursor-pointer p-0 mb-2 hover:text-gray-700;
+}
+
+.toggle-advanced .pi {
+ @apply text-xs;
}
-.current-link-view__action-button {
- @apply w-1/2;
+.link-editor-popover__advanced {
+ @apply flex flex-col mb-1;
}
.listbox-item {
diff --git a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.html b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.html
index 6aa82d8c673c..72966b5ef35e 100644
--- a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.html
+++ b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.html
@@ -79,15 +79,81 @@
{{ existingLinkUrl() }}
-
-
-
+
+
+
+
+
+ @if (showAdvanced()) {
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
diff --git a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.spec.ts b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.spec.ts
index e5b2fb7e49a5..e33093ede7b9 100644
--- a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.spec.ts
+++ b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.spec.ts
@@ -1,25 +1,222 @@
+import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClientTesting } from '@angular/common/http/testing';
+import { Component, viewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Editor } from '@tiptap/core';
+import { Document } from '@tiptap/extension-document';
+import { Link } from '@tiptap/extension-link';
+import { Paragraph } from '@tiptap/extension-paragraph';
+import { Text } from '@tiptap/extension-text';
+
import { DotLinkEditorPopoverComponent } from './dot-link-editor-popover.component';
+@Component({
+ template: `
+
+ `,
+ imports: [DotLinkEditorPopoverComponent]
+})
+class TestHostComponent {
+ editor: Editor;
+ popover = viewChild.required(DotLinkEditorPopoverComponent);
+
+ constructor() {
+ this.editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Link.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ title: { default: null },
+ 'aria-label': { default: null },
+ rel: { default: null }
+ };
+ }
+ }).configure({ autolink: false, openOnClick: false })
+ ],
+ content: '
Hello world
'
+ });
+ }
+}
+
+function mockEditorChain(editor: Editor) {
+ const runSpy = jest.fn();
+ const setLinkSpy = jest.fn().mockReturnValue({ run: runSpy });
+ const focusSpy = jest.fn().mockReturnValue({ setLink: setLinkSpy });
+ jest.spyOn(editor, 'chain').mockReturnValue({ focus: focusSpy } as never);
+
+ return { setLinkSpy, focusSpy, runSpy };
+}
+
describe('DotLinkEditorPopoverComponent', () => {
- let component: DotLinkEditorPopoverComponent;
- let fixture: ComponentFixture
;
+ let fixture: ComponentFixture;
+ let hostComponent: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
- declarations: [DotLinkEditorPopoverComponent],
- teardown: { destroyAfterEach: false }
+ imports: [DotLinkEditorPopoverComponent, TestHostComponent],
+ providers: [provideHttpClient(), provideHttpClientTesting()]
}).compileComponents();
- });
- beforeEach(() => {
- fixture = TestBed.createComponent(DotLinkEditorPopoverComponent);
- component = fixture.componentInstance;
+ fixture = TestBed.createComponent(TestHostComponent);
+ hostComponent = fixture.componentInstance;
fixture.detectChanges();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ afterEach(() => {
+ hostComponent.editor.destroy();
+ });
+
+ it('should create the component', () => {
+ expect(hostComponent.popover()).toBeTruthy();
+ });
+
+ it('should expose target options with all standard values', () => {
+ const component = hostComponent.popover();
+ const values = component.targetOptions.map((o) => o.value);
+ expect(values).toEqual(['_blank', '_self', '_parent', '_top']);
+ });
+
+ it('should expose rel options with predefined values', () => {
+ const component = hostComponent.popover();
+ const values = component.relOptions.map((o) => o.value);
+ expect(values).toContain('noopener noreferrer');
+ expect(values).toContain('nofollow');
+ expect(values).toContain('sponsored');
+ expect(values).toContain('ugc');
+ });
+
+ it('should initialize with default signal values', () => {
+ const component = hostComponent.popover();
+ expect(component['linkTitle']()).toBe('');
+ expect(component['linkAriaLabel']()).toBe('');
+ expect(component['linkRel']()).toBeNull();
+ expect(component['showAdvanced']()).toBe(false);
+ expect(component['linkTargetAttribute']()).toBe('_blank');
+ });
+
+ describe('addLinkToNode', () => {
+ beforeEach(() => {
+ const component = hostComponent.popover();
+ component['popover'] = { hide: jest.fn() } as never;
+ });
+
+ it('should call setLink with all accessibility attributes', () => {
+ const component = hostComponent.popover();
+ const editor = hostComponent.editor;
+ const { setLinkSpy } = mockEditorChain(editor);
+ jest.spyOn(editor, 'isActive').mockReturnValue(false);
+
+ component['linkTargetAttribute'].set('_self');
+ component['linkTitle'].set('My Title');
+ component['linkAriaLabel'].set('Click here');
+ component['linkRel'].set('nofollow');
+
+ component['addLinkToNode']('https://example.com');
+
+ expect(setLinkSpy).toHaveBeenCalledWith({
+ href: 'https://example.com',
+ target: '_self',
+ title: 'My Title',
+ 'aria-label': 'Click here',
+ rel: 'nofollow'
+ });
+ });
+
+ it('should trim whitespace-only values to null', () => {
+ const component = hostComponent.popover();
+ const editor = hostComponent.editor;
+ const { setLinkSpy } = mockEditorChain(editor);
+ jest.spyOn(editor, 'isActive').mockReturnValue(false);
+
+ component['linkTitle'].set(' ');
+ component['linkAriaLabel'].set(' ');
+ component['linkRel'].set(null);
+
+ component['addLinkToNode']('https://example.com');
+
+ expect(setLinkSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: null,
+ 'aria-label': null,
+ rel: null
+ })
+ );
+ });
+ });
+
+ describe('saveLinkAttributes', () => {
+ beforeEach(() => {
+ const component = hostComponent.popover();
+ component['popover'] = { hide: jest.fn() } as never;
+ });
+
+ it('should save all attributes to an existing link', () => {
+ const component = hostComponent.popover();
+ const editor = hostComponent.editor;
+ const { setLinkSpy } = mockEditorChain(editor);
+
+ component['existingLinkUrl'].set('https://dotcms.com');
+ component['linkTargetAttribute'].set('_blank');
+ component['linkTitle'].set('dotCMS');
+ component['linkAriaLabel'].set('Visit dotCMS');
+ component['linkRel'].set('noopener noreferrer');
+
+ component['saveLinkAttributes']();
+
+ expect(setLinkSpy).toHaveBeenCalledWith({
+ href: 'https://dotcms.com',
+ target: '_blank',
+ title: 'dotCMS',
+ 'aria-label': 'Visit dotCMS',
+ rel: 'noopener noreferrer'
+ });
+ });
+ });
+
+ describe('initializeExistingLinkData', () => {
+ it('should auto-expand advanced section when existing link has accessibility attrs', () => {
+ const component = hostComponent.popover();
+ const editor = hostComponent.editor;
+
+ jest.spyOn(editor, 'isActive').mockReturnValue(true);
+ jest.spyOn(editor, 'getAttributes').mockReturnValue({
+ href: 'https://dotcms.com',
+ target: '_self',
+ title: 'My title',
+ 'aria-label': 'Visit site',
+ rel: 'nofollow'
+ });
+
+ component['initializeExistingLinkData']();
+
+ expect(component['showAdvanced']()).toBe(true);
+ expect(component['linkTitle']()).toBe('My title');
+ expect(component['linkAriaLabel']()).toBe('Visit site');
+ expect(component['linkRel']()).toBe('nofollow');
+ expect(component['linkTargetAttribute']()).toBe('_self');
+ });
+
+ it('should keep advanced section collapsed when no accessibility attrs exist', () => {
+ const component = hostComponent.popover();
+ const editor = hostComponent.editor;
+
+ jest.spyOn(editor, 'isActive').mockReturnValue(true);
+ jest.spyOn(editor, 'getAttributes').mockReturnValue({
+ href: 'https://example.com',
+ target: '_blank'
+ });
+
+ component['initializeExistingLinkData']();
+
+ expect(component['showAdvanced']()).toBe(false);
+ expect(component['linkTitle']()).toBe('');
+ expect(component['linkAriaLabel']()).toBe('');
+ expect(component['linkRel']()).toBeNull();
+ });
});
});
diff --git a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.ts b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.ts
index 766e6e9e1024..afdb986fae2b 100644
--- a/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.ts
+++ b/core-web/libs/block-editor/src/lib/elements/dot-bubble-menu/components/dot-link-editor-popover/dot-link-editor-popover.component.ts
@@ -16,9 +16,9 @@ import {
import { FormsModule } from '@angular/forms';
import { Button } from 'primeng/button';
-import { Checkbox } from 'primeng/checkbox';
import { InputText } from 'primeng/inputtext';
import { Listbox } from 'primeng/listbox';
+import { Select } from 'primeng/select';
import { Skeleton } from 'primeng/skeleton';
import { debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
@@ -29,6 +29,19 @@ import { DotCMSContentlet } from '@dotcms/dotcms-models';
import { EditorModalDirective } from '../../../../directive/editor-modal.directive';
+/**
+ * Extended link attributes supported by the block editor's custom Link mark.
+ * Includes standard HTML anchor attributes plus accessibility-related fields
+ * (title, aria-label) registered via TipTap's `addAttributes()`.
+ */
+interface LinkAttributes {
+ href: string;
+ target?: string | null;
+ rel?: string | null;
+ title?: string | null;
+ 'aria-label'?: string | null;
+}
+
interface SearchResultItem {
displayName: string;
url: string;
@@ -36,19 +49,37 @@ interface SearchResultItem {
inode?: string;
}
+/** Available options for the HTML `target` attribute on links. */
+const TARGET_OPTIONS = [
+ { label: 'New window (_blank)', value: '_blank' },
+ { label: 'Same window (_self)', value: '_self' },
+ { label: 'Parent frame (_parent)', value: '_parent' },
+ { label: 'Full window (_top)', value: '_top' }
+];
+
+/** Available options for the HTML `rel` attribute on links. */
+const REL_OPTIONS = [
+ { label: 'noopener noreferrer', value: 'noopener noreferrer' },
+ { label: 'noopener', value: 'noopener' },
+ { label: 'noreferrer', value: 'noreferrer' },
+ { label: 'nofollow', value: 'nofollow' },
+ { label: 'sponsored', value: 'sponsored' },
+ { label: 'ugc', value: 'ugc' }
+];
+
/**
* A popover component for creating and editing links in the DotCMS block editor.
* This component provides functionality to:
* - Search for internal content to link to
* - Create links to external URLs
- * - Edit existing link properties (URL, target attribute)
+ * - Edit existing link properties (URL, target, title, aria-label, rel)
* - Remove links from selected text or images
*/
@Component({
selector: 'dot-link-editor-popover',
templateUrl: './dot-link-editor-popover.component.html',
styleUrls: ['./dot-link-editor-popover.component.css'],
- imports: [FormsModule, Listbox, InputText, Skeleton, Button, Checkbox, EditorModalDirective]
+ imports: [FormsModule, Listbox, InputText, Select, Skeleton, Button, EditorModalDirective]
})
export class DotLinkEditorPopoverComponent implements OnDestroy {
@ViewChild('popover', { read: EditorModalDirective }) private popover: EditorModalDirective;
@@ -64,6 +95,21 @@ export class DotLinkEditorPopoverComponent implements OnDestroy {
protected readonly existingLinkUrl = signal(null);
protected readonly linkTargetAttribute = signal('_blank');
+ /** Link title attribute for tooltip display and accessibility. */
+ protected readonly linkTitle = signal('');
+
+ /** Link aria-label attribute for screen reader accessibility. */
+ protected readonly linkAriaLabel = signal('');
+
+ /** Link rel attribute controlling the relationship between current and linked document. */
+ protected readonly linkRel = signal(null);
+
+ /** Whether the advanced accessibility fields section is expanded. */
+ protected readonly showAdvanced = signal(false);
+
+ readonly targetOptions = TARGET_OPTIONS;
+ readonly relOptions = REL_OPTIONS;
+
protected readonly showLoading = computed(
() => this.isSearching() && !this.showLinkDetails() && !this.isFullURL()
);
@@ -186,11 +232,12 @@ export class DotLinkEditorPopoverComponent implements OnDestroy {
const searchResult = this.searchResults()[searchResultIndex];
const linkToSave = searchResult?.url || linkUrl;
+ const linkAttrs = this.#buildLinkAttributes(linkToSave, target);
if (isImageNode) {
this.editor().chain().focus().setImageLink({ href: linkToSave, target }).run();
} else {
- this.editor().chain().focus().setLink({ href: linkToSave, target }).run();
+ this.#applyLink(linkAttrs);
}
this.popover.hide();
@@ -211,11 +258,21 @@ export class DotLinkEditorPopoverComponent implements OnDestroy {
private initializeExistingLinkData() {
const isTextLink = this.editor().isActive('link');
const linkAttrs = this.editor().getAttributes(isTextLink ? 'link' : 'dotImage');
- const { href: existingUrl = '', target: existingTarget = '_blank' } = linkAttrs;
+ const {
+ href: existingUrl = '',
+ target: existingTarget = '_blank',
+ title: existingTitle = '',
+ 'aria-label': existingAriaLabel = '',
+ rel: existingRel = null
+ } = linkAttrs;
this.searchQuery.set(existingUrl);
this.existingLinkUrl.set(existingUrl);
this.linkTargetAttribute.set(existingTarget);
+ this.linkTitle.set(existingTitle || '');
+ this.linkAriaLabel.set(existingAriaLabel || '');
+ this.linkRel.set(existingRel || null);
+ this.showAdvanced.set(!!(existingTitle || existingAriaLabel || existingRel));
this.searchResults.set([]);
}
@@ -242,20 +299,44 @@ export class DotLinkEditorPopoverComponent implements OnDestroy {
}
/**
- * Updates the target attribute of an existing link based on user preference.
- * Allows users to control whether links open in the same window or a new tab.
+ * Saves all link attributes (target, title, aria-label, rel) to the existing link.
*/
- protected updateLinkTargetAttribute(event: { checked: boolean }) {
- const shouldOpenInNewWindow = event.checked;
- const newTargetValue = shouldOpenInNewWindow ? '_blank' : '_self';
+ protected saveLinkAttributes() {
+ const url = this.existingLinkUrl();
+ if (!url?.trim()) return;
+ const linkAttrs = this.#buildLinkAttributes(url, this.linkTargetAttribute());
+ this.#applyLink(linkAttrs);
+ this.popover.hide();
+ }
+
+ /**
+ * Applies link attributes to the editor selection.
+ * Uses type assertion because our extended Link mark supports
+ * additional attributes (title, aria-label) beyond the base type definition.
+ */
+ #applyLink(attrs: LinkAttributes) {
this.editor()
.chain()
- .setLink({ href: this.existingLinkUrl(), target: newTargetValue })
+ .focus()
+ .setLink(attrs as { href: string; target?: string; rel?: string; class?: string })
.run();
+ }
- this.linkTargetAttribute.set(newTargetValue);
- this.popover.hide();
+ /**
+ * Builds a complete LinkAttributes object from the current signal state.
+ * Whitespace-only title and aria-label values are trimmed to null.
+ * @param href - The link URL.
+ * @param target - The link target attribute.
+ */
+ #buildLinkAttributes(href: string, target: string): LinkAttributes {
+ return {
+ href,
+ target,
+ rel: this.linkRel() || null,
+ title: this.linkTitle()?.trim() || null,
+ 'aria-label': this.linkAriaLabel()?.trim() || null
+ };
}
/**
diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/text.component.ts b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/text.component.ts
index 747bde4c8f23..c1532a396def 100644
--- a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/text.component.ts
+++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/text.component.ts
@@ -85,7 +85,10 @@ interface TextBlockProps {
@case ('link') {
+ [attr.target]="$currentAttrs()['target'] || ''"
+ [attr.title]="$currentAttrs()['title'] || null"
+ [attr.aria-label]="$currentAttrs()['aria-label'] || null"
+ [attr.rel]="$currentAttrs()['rel'] || null">
}
diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm b/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm
index 09e100db2f42..af4f61c080ee 100644
--- a/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm
+++ b/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm
@@ -27,7 +27,11 @@
*##*
*##end#*
*##if ($mark.type == "link")#*
- *##*
+ *##*
*##end#*
*##end#*
*##end#*