Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions config/locales/js-en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,14 @@ en:
wysiwyg: "Switch to WYSIWYG editor"
macro:
attribute_reference:
aria_label_resource_link: "A dynamic link to a %{resource} placed using a macro"
aria_label_with_name: "%{name}. %{description}"
aria_label_work_package_attribute: "A dynamic work package attribute placed using a macro"
aria_label_work_package_link: "A dynamic link to a work package placed using a macro"
invalid_attribute: "The selected attribute '%{name}' does not exist."
macro_help_tooltip: "This text segment is being dynamically rendered by a macro."
not_found: "Requested resource could not be found"
nested_macro: "This macro is recursively referencing %{model} %{id}."
invalid_attribute: "The selected attribute '%{name}' does not exist."
not_found: "Requested resource could not be found"
child_pages:
button: "List of sub-pages"
include_parent: "Include parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
// See COPYRIGHT and LICENSE files for more details.
//++ Ng1FieldControlsWrapper,

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Injector, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Injector, OnInit, inject } from '@angular/core';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import {
Expand Down Expand Up @@ -64,12 +64,12 @@ export class AttributeLabelMacroComponent implements OnInit {
error:string|null = null;

text = {
help: this.I18n.t('js.editor.macro.attribute_reference.macro_help_tooltip'),
aria_label: this.I18n.t('js.editor.macro.attribute_reference.aria_label_work_package_attribute'),
not_found: this.I18n.t('js.editor.macro.attribute_reference.not_found'),
invalid_attribute: (attr:string) => this.I18n.t('js.editor.macro.attribute_reference.invalid_attribute', { name: attr }),
};

@HostBinding('title') hostTitle = this.text.help;
showAriaContext = false;

// The loaded resource, required for help text
resource:HalResource|null = null;
Expand All @@ -88,6 +88,7 @@ export class AttributeLabelMacroComponent implements OnInit {
const model = element.dataset.model as SupportedAttributeModels;
const id = element.dataset.id!;
const attributeName = element.dataset.attribute!;
this.showAriaContext = model === 'workPackage';
this.attributeScope = capitalize(model);

void this.loadResourceAttribute(model, id, attributeName);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
@if (resource) {
<span [textContent]="label"></span>
@if (showAriaContext) {
<span class="sr-only" [textContent]="text.aria_label"></span>
}
<attribute-help-text [attribute]="attribute"
[attributeScope]="attributeScope" />
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
// See COPYRIGHT and LICENSE files for more details.
//++ Ng1FieldControlsWrapper,

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Injector, OnInit, ViewChild, inject } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Injector, OnInit, ViewChild, inject } from '@angular/core';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import {
Expand Down Expand Up @@ -68,13 +68,13 @@ export class AttributeValueMacroComponent implements OnInit {
error:string|null = null;

text = {
help: this.I18n.t('js.editor.macro.attribute_reference.macro_help_tooltip'),
aria_label: this.I18n.t('js.editor.macro.attribute_reference.aria_label_work_package_attribute'),
placeholder: this.I18n.t('js.placeholders.default'),
not_found: this.I18n.t('js.editor.macro.attribute_reference.not_found'),
invalid_attribute: (attr:string) => this.I18n.t('js.editor.macro.attribute_reference.invalid_attribute', { name: attr }),
};

@HostBinding('title') hostTitle = this.text.help;
showAriaContext = false;

resource:HalResource;

Expand All @@ -86,6 +86,7 @@ export class AttributeValueMacroComponent implements OnInit {
const id = element.dataset.id!;
const attributeName = element.dataset.attribute!;
element.classList.add(ATTRIBUTE_MACRO_CLASS);
this.showAriaContext = model === 'workPackage';

if (this.isNestedMacro(model, id, attributeName)) {
const error = this.I18n.t('js.editor.macro.attribute_reference.nested_macro', { model, id });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@
containerType="single-view"
[displayFieldOptions]="{ writable: false }"
[fieldName]="fieldName" />
@if (showAriaContext) {
<span class="sr-only" [textContent]="text.aria_label"></span>
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
// See COPYRIGHT and LICENSE files for more details.
//++ Ng1FieldControlsWrapper,

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Injector, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Injector, OnInit, inject } from '@angular/core';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
Expand Down Expand Up @@ -66,11 +66,12 @@ export class WorkPackageQuickinfoMacroComponent implements OnInit {

text = {
not_found: this.I18n.t('js.editor.macro.attribute_reference.not_found'),
help: this.I18n.t('js.editor.macro.attribute_reference.macro_help_tooltip'),
aria_label: (name:string) => this.I18n.t('js.editor.macro.attribute_reference.aria_label_with_name', {
name,
description: String(this.I18n.t('js.editor.macro.attribute_reference.aria_label_work_package_link')),
}),
};

@HostBinding('title') hostTitle = this.text.help;

/** Work package to be shown */
workPackage$:Observable<WorkPackageResource>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
fieldName="type" />
<a data-hover-card-trigger-target="trigger"
target="_top"
[attr.aria-label]="text.aria_label(workPackage.formattedId)"
[href]="pathHelper.workPackagePath(workPackage.displayId)"
[attr.data-work-package-id]="workPackage.id"
[attr.data-hover-card-url]="workPackageHoverCardUrl">
Expand Down
4 changes: 2 additions & 2 deletions lib/open_project/object_linking.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def link_to_user(user, options = {}) # rubocop:disable Metrics/AbcSize

only_path = options.delete(:only_path) { true }
name = options.delete(:name) { user.name }
options[:title] ||= I18n.t(:label_user_named, name:)
options[:title] = I18n.t(:label_user_named, name:) unless options.key?(:title)

add_hover_card_options(user, options, only_path:)

Expand All @@ -63,7 +63,7 @@ def link_to_group(group, options = {})
name = group.name
href = show_group_url(group,
only_path: options.delete(:only_path) { true })
options[:title] ||= I18n.t(:label_group_named, name:)
options[:title] = I18n.t(:label_group_named, name:) unless options.key?(:title)

link_to(name, href, options)
end
Expand Down
25 changes: 22 additions & 3 deletions lib/open_project/text_formatting/filters/mention_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class MentionFilter < HTML::Pipeline::Filter
include ActionView::Helpers::UrlHelper
include OpenProject::ObjectLinking
include OpenProject::StaticRouting::UrlHelpers
include OpenProject::TextFormatting::Helpers::AccessibleLinkLabel

def call
preload_mentions
Expand Down Expand Up @@ -102,13 +103,15 @@ def mention_anchor(mention)
def user_mention(user)
link_to_user(user,
only_path: context[:only_path],
class: "user-mention")
class: "user-mention",
aria: { label: accessible_link_label(user.name, resource_link_aria_label("user")) })
end

def group_mention(group)
link_to_group(group,
only_path: context[:only_path],
class: "user-mention")
class: "user-mention",
aria: { label: accessible_link_label(group.name, resource_link_aria_label("group")) })
end

def work_package_mention(work_package, mention)
Expand Down Expand Up @@ -145,14 +148,24 @@ def work_package_static_macro(work_package, detailed:)

link_to(label,
work_package_path_or_url(id: work_package.display_id, only_path: context[:only_path]),
class: "issue work_package")
class: "issue work_package",
aria: { label: accessible_link_label(label, work_package_link_aria_label) })
end

def work_package_link_aria_label
I18n.t("js.editor.macro.attribute_reference.aria_label_work_package_link")
end

def resource_link_aria_label(resource)
I18n.t("js.editor.macro.attribute_reference.aria_label_resource_link", resource:)
end

def work_package_link(work_package)
display_id = work_package.display_id
link_to(work_package.formatted_id,
work_package_path_or_url(id: display_id, only_path: context[:only_path]),
class: "issue work_package",
aria: { label: accessible_link_label(work_package.formatted_id, work_package_link_aria_label) },
data: {
hover_card_trigger_target: "trigger",
hover_card_url: hover_card_work_package_path(display_id)
Expand All @@ -179,6 +192,12 @@ def fallback_text(mention)
# For link_to
def controller; end

def link_to(name = nil, options = nil, html_options = nil, &)
html_options = (html_options || {}).except(:title, "title")

super(name, options, html_options, &) # rubocop:disable Style/SuperArguments
end

def mention_id(mention)
value = mention.attributes["data-id"]&.value
# Reject semantic-shaped data-ids: `PROJ-42` must not silently
Expand Down
46 changes: 46 additions & 0 deletions lib/open_project/text_formatting/helpers/accessible_link_label.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module OpenProject::TextFormatting
module Helpers
# Shared helper for building composite ARIA labels on macro-generated links.
# Combines the visible link text with a plain-text context description,
# stripping any HTML from both before interpolating.
module AccessibleLinkLabel
def accessible_link_label(name, description)
visible_name = Nokogiri::HTML.fragment(name.to_s).text.squish
plain_description = Nokogiri::HTML.fragment(description.to_s).text.squish
I18n.t("js.editor.macro.attribute_reference.aria_label_with_name",
name: visible_name,
description: plain_description)
end
end
end
end
17 changes: 17 additions & 0 deletions lib/open_project/text_formatting/matchers/link_handlers/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module OpenProject::TextFormatting::Matchers
module LinkHandlers
class Base
include ::OpenProject::TextFormatting::Truncation
include ::OpenProject::TextFormatting::Helpers::AccessibleLinkLabel
# used for the work package quick links
include WorkPackagesHelper
# Used for escaping helper 'h()'
Expand Down Expand Up @@ -97,6 +98,22 @@ def named_route(name, **args)
end

def controller; end

private

def link_to(name = nil, options = nil, html_options = nil, &)
html_options ||= {}
title = html_options.delete(:title) || html_options.delete("title")
description = [resource_link_aria_label, title].compact.join(". ")
aria = (html_options[:aria] || {}).merge(label: accessible_link_label(name, description))

super(name, options, html_options.merge(aria:), &)
end

def resource_link_aria_label
resource = matcher.prefix.presence || (matcher.sep == "r" ? "revision" : "resource")
I18n.t("js.editor.macro.attribute_reference.aria_label_resource_link", resource:)
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def render_project

def render_user
if (user = User.visible.find_by(login: oid))
link_to_user(user, only_path: context[:only_path], class: "user-mention")
link_to_user(user, only_path: context[:only_path], class: "user-mention", title: nil)
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ def render_user
if user
link_to_user(user,
only_path: context[:only_path],
class: "user-mention")
class: "user-mention",
title: nil)
end
end

Expand All @@ -119,7 +120,8 @@ def render_group
if group
link_to_group(group,
only_path: context[:only_path],
class: "user-mention")
class: "user-mention",
title: nil)
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ def render_work_package_link(work_package, fallback_id:)
})
end

def work_package_link_aria_label
I18n.t("js.editor.macro.attribute_reference.aria_label_work_package_link")
end

alias_method :resource_link_aria_label, :work_package_link_aria_label

# A nil WP means classic mode skipped the preload, or the reference
# didn't resolve — neither case needs visibility gating.
def text_only?(work_package)
Expand Down
13 changes: 11 additions & 2 deletions modules/documents/spec/application_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
link_to("Test document",
{ controller: "documents", action: "show", id: document.id },
class: "document op-uc-link",
aria: { label: "Test document. A dynamic link to a document placed using a macro" },
target: "_top")
end

Expand Down Expand Up @@ -108,15 +109,23 @@
subject { format_text("#{identifier}:document##{document.id}", project: the_other_project) }

it {
expect(subject).to be_html_eql("<p class=\"op-uc-p\"><a class=\"document op-uc-link\" href=\"/documents/#{document.id}\" target=\"_top\">Test document</a></p>")
expect(subject).to be_html_eql(
"<p class=\"op-uc-p\"><a class=\"document op-uc-link\" " \
"aria-label=\"Test document. A dynamic link to a document placed using a macro\" " \
"href=\"/documents/#{document.id}\" target=\"_top\">Test document</a></p>"
)
}
end

context "By name and given project" do
subject { format_text("#{identifier}:document:\"#{document.title}\"", project: the_other_project) }

it {
expect(subject).to be_html_eql("<p class=\"op-uc-p\"><a class=\"document op-uc-link\" href=\"/documents/#{document.id}\" target=\"_top\">Test document</a></p>")
expect(subject).to be_html_eql(
"<p class=\"op-uc-p\"><a class=\"document op-uc-link\" " \
"aria-label=\"Test document. A dynamic link to a document placed using a macro\" " \
"href=\"/documents/#{document.id}\" target=\"_top\">Test document</a></p>"
)
}
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def controller
"My document",
{ controller: "/documents", action: "show", id: document.id, only_path: true },
class: "document op-uc-link",
aria: { label: "My document. A dynamic link to a document placed using a macro" },
target: "_top"
)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def controller
"Monthly coordination",
{ controller: "/meetings", action: "show", project_id: project.id, id: meeting.id, only_path: true },
class: "meeting op-uc-link",
aria: { label: "Monthly coordination. A dynamic link to a meeting placed using a macro" },
target: "_top"
)
end
Expand Down
Loading
Loading