Repository: HealthResearchAuthority/rsp-iras-portal
Pull request #47: Accessibility - ARIA description tag helper
Author: login: vinaylingappa-HRA
Created: 2025-04-16T14:37:49Z | Merged: 2025-04-23T17:08:21Z

## What was the purpose of this ticket?
Fixes the aria-describedby for question set

## Jira Ref
[RSP-3119](https://nihr.atlassian.net/browse/RSP-3119)

## Type of Change

Put [X] inside the [] to make the box ticked

- [] New feature
- [] Refactoring
- [x] Bug-fix
- [] DevOps
- [] Testing

## Solution

What work was completed to cover off the story?

- List out changes made to the repo

## Checklist

- [ ] Your code builds clean without any  warnings
- [ ] You have added unit tests
- [ ] All the added unit tests add value i.e. assert the right thing?
- [ ] You are using the correct naming conventions
- [ ] You have fixed all the suggestions made by .editorconfig and/or Roslynator
- [ ] There are no dead code and no "TODO" comments left
- [ ] Please make sure that using statements are sorted with using System* statements at the top. You can use CTRL + K, CTRL + D command to format the file, that will respect the .editorconfig, or use the Visual Studio extension called [CodeMaid](http://www.codemaid.net/) to do that for you automatically on save.


Diff:
diff --git a/src/Web/Rsp.IrasPortal.Web/Controllers/ApplicationController.cs b/src/Web/Rsp.IrasPortal.Web/Controllers/ApplicationController.cs
index 88282baa7..06213b10f 100644
--- a/src/Web/Rsp.IrasPortal.Web/Controllers/ApplicationController.cs
+++ b/src/Web/Rsp.IrasPortal.Web/Controllers/ApplicationController.cs
@@ -21,7 +21,7 @@ namespace Rsp.IrasPortal.Web.Controllers;
 public class ApplicationController(
     IApplicationsService applicationsService,
     IValidator<ApplicationInfoViewModel> validator,
-    IValidator<IrasIdCheckViewModel> irasIdValidator,
+    IValidator<IrasIdViewModel> irasIdValidator,
     IQuestionSetService questionSetService) : Controller
 {
     // ApplicationInfo view name
@@ -31,12 +31,12 @@ public class ApplicationController(
     [Route("/", Name = "app:welcome")]
     public IActionResult Welcome() => View(nameof(Index));
 
-    public IActionResult IrasIdCheck() => View(nameof(IrasIdCheck));
+    public IActionResult StartProject() => View(nameof(StartProject));
 
     [HttpPost]
-    public async Task<IActionResult> IrasIdCheck(IrasIdCheckViewModel model)
+    public async Task<IActionResult> StartProject(IrasIdViewModel model)
     {
-        var validationResult = await irasIdValidator.ValidateAsync(new ValidationContext<IrasIdCheckViewModel>(model));
+        var validationResult = await irasIdValidator.ValidateAsync(new ValidationContext<IrasIdViewModel>(model));
 
         if (!validationResult.IsValid)
         {
@@ -45,7 +45,7 @@ public async Task<IActionResult> IrasIdCheck(IrasIdCheckViewModel model)
                 ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
             }
 
-            return View(nameof(IrasIdCheck), model);
+            return View(nameof(StartProject), model);
         }
 
         // Get existing applications
@@ -60,7 +60,7 @@ public async Task<IActionResult> IrasIdCheck(IrasIdCheckViewModel model)
         if (irasIdExists)
         {
             ModelState.AddModelError(nameof(model.IrasId), "A record for the project with this IRAS ID already exists");
-            return View(nameof(IrasIdCheck), model);
+            return View(nameof(StartProject), model);
         }
 
         // Create new application
diff --git a/src/Web/Rsp.IrasPortal.Web/Models/IrasIdCheckViewModel.cs b/src/Web/Rsp.IrasPortal.Web/Models/IrasIdCheckViewModel.cs
deleted file mode 100644
index 0015ddf99..000000000
--- a/src/Web/Rsp.IrasPortal.Web/Models/IrasIdCheckViewModel.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-﻿using System.ComponentModel.DataAnnotations;
-
-namespace Rsp.IrasPortal.Web.Models;
-
-public class IrasIdCheckViewModel
-{
-    public string? IrasId { get; set; }
-}
\ No newline at end of file
diff --git a/src/Web/Rsp.IrasPortal.Web/Models/IrasIdViewModel.cs b/src/Web/Rsp.IrasPortal.Web/Models/IrasIdViewModel.cs
new file mode 100644
index 000000000..243e42ec9
--- /dev/null
+++ b/src/Web/Rsp.IrasPortal.Web/Models/IrasIdViewModel.cs
@@ -0,0 +1,6 @@
+﻿namespace Rsp.IrasPortal.Web.Models;
+
+public class IrasIdViewModel
+{
+    public string? IrasId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityTagHelper.cs b/src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityTagHelper.cs
new file mode 100644
index 000000000..c1732f4db
--- /dev/null
+++ b/src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityTagHelper.cs
@@ -0,0 +1,91 @@
+﻿using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+
+namespace Rsp.IrasPortal.Web.TagHelpers;
+
+/// <summary>
+/// A TagHelper that automatically generates `aria-describedby` and `id` attributes for accessibility purposes,
+/// based on bound model expressions.
+/// </summary>
+[HtmlTargetElement(Attributes = AriaDescribedByAttributeName)]
+[HtmlTargetElement(Attributes = AriaIdAttributeName)]
+public class AccessibilityTagHelper : TagHelper
+{
+    // Attribute names that trigger this tag helper
+    private const string AriaDescribedByAttributeName = "aria-described-for";
+
+    private const string AriaIdAttributeName = "aria-id-for";
+
+    /// <summary>
+    /// Model binding expression for generating the `aria-describedby` attribute.
+    /// </summary>
+    [HtmlAttributeName(AriaDescribedByAttributeName)]
+    public ModelExpression DescribedFor { get; set; } = null!;
+
+    /// <summary>
+    /// Model binding expression for generating the `id` attribute.
+    /// </summary>
+    [HtmlAttributeName(AriaIdAttributeName)]
+    public ModelExpression IdFor { get; set; } = null!;
+
+    /// <summary>
+    /// Processes the tag and injects the appropriate accessibility attributes.
+    /// </summary>
+    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
+    {
+        // Let the base tag helper logic run first
+        await base.ProcessAsync(context, output);
+
+        // Try to get a readable value from the bound model property
+        var propertyName = (DescribedFor?.Model ?? IdFor?.Model) as string;
+
+        // If there's no meaningful input, skip processing
+        if (string.IsNullOrWhiteSpace(propertyName))
+        {
+            return;
+        }
+
+        // Build a safe string to be used as an ARIA attribute (e.g., question-text-hint)
+        var ariaDescribedBy = BuildAriaDescribedBy(propertyName);
+
+        // Set the aria-describedby attribute only if requested via DescribedFor
+        if (!string.IsNullOrWhiteSpace(DescribedFor?.Name))
+        {
+            output.Attributes.SetAttribute("aria-describedby", ariaDescribedBy);
+        }
+
+        // Set the id attribute only if requested via IdFor
+        if (!string.IsNullOrWhiteSpace(IdFor?.Name))
+        {
+            output.Attributes.SetAttribute("id", ariaDescribedBy);
+        }
+    }
+
+    /// <summary>
+    /// Sanitizes the provided string to ensure it is safe for use in HTML attributes.
+    /// Replaces any character not allowed in HTML IDs with a dash and appends `-hint`.
+    /// </summary>
+    private static string BuildAriaDescribedBy(string? input)
+    {
+        if (string.IsNullOrWhiteSpace(input))
+        {
+            return string.Empty;
+        }
+
+        // Replace anything not alphanumeric or a dash with "-"
+        var safeHint = SanitizeInputRegex().Replace(input, "-");
+
+        // Clean up leading/trailing dashes, make lowercase, and append `-hint`
+        return $"{safeHint.Trim('-').ToLowerInvariant()}-hint";
+    }
+
+    /// <summary>
+    /// Regex pattern that removes all characters except letters, numbers, and dashes.
+    /// Used to sanitize strings for HTML attribute safety.
+    /// </summary>
+    private static Regex SanitizeInputRegex()
+    {
+        return new Regex(@"[^a-zA-Z0-9\-]", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
+    }
+}
\ No newline at end of file
diff --git a/src/Web/Rsp.IrasPortal.Web/Validators/IrasIdCheckValidator.cs b/src/Web/Rsp.IrasPortal.Web/Validators/IrasIdViewModelValidator.cs
similarity index 80%
rename from src/Web/Rsp.IrasPortal.Web/Validators/IrasIdCheckValidator.cs
rename to src/Web/Rsp.IrasPortal.Web/Validators/IrasIdViewModelValidator.cs
index fb08f8478..3852b7237 100644
--- a/src/Web/Rsp.IrasPortal.Web/Validators/IrasIdCheckValidator.cs
+++ b/src/Web/Rsp.IrasPortal.Web/Validators/IrasIdViewModelValidator.cs
@@ -3,9 +3,9 @@
 
 namespace Rsp.IrasPortal.Web.Validators;
 
-public class IrasIdCheckValidator : AbstractValidator<IrasIdCheckViewModel>
+public class IrasIdViewModelValidator : AbstractValidator<IrasIdViewModel>
 {
-    public IrasIdCheckValidator()
+    public IrasIdViewModelValidator()
     {
         // Validate IRAS ID
         RuleFor(x => x.IrasId)
diff --git a/src/Web/Rsp.IrasPortal.Web/Views/Application/CreateApplication.cshtml b/src/Web/Rsp.IrasPortal.Web/Views/Application/CreateApplication.cshtml
index 9d22a4cc6..0d2466cd7 100644
--- a/src/Web/Rsp.IrasPortal.Web/Views/Application/CreateApplication.cshtml
+++ b/src/Web/Rsp.IrasPortal.Web/Views/Application/CreateApplication.cshtml
@@ -20,7 +20,7 @@
 				You'll be able to save your progress and come back to it later if you need to.
 			</p>
 
-			<a class="gem-c-button govuk-button govuk-button--start" asp-route="app:IrasIdCheck" aria-label="Start creating a project record">
+			<a class="gem-c-button govuk-button govuk-button--start" asp-route="app:startproject" aria-label="Start creating a project record">
 				Start
 				<svg class="govuk-button__start-icon govuk-!-display-none-print" xmlns="http://www.w3.org/2000/svg" width="17.5" height="19" viewBox="0 0 33 40" focusable="false" aria-hidden="true">
 					<path fill="currentColor" d="M0 0h13l20 20-20 20H0l20-20z"></path>
diff --git a/src/Web/Rsp.IrasPortal.Web/Views/Application/IrasIdCheck.cshtml b/src/Web/Rsp.IrasPortal.Web/Views/Application/startproject.cshtml
similarity index 62%
rename from src/Web/Rsp.IrasPortal.Web/Views/Application/IrasIdCheck.cshtml
rename to src/Web/Rsp.IrasPortal.Web/Views/Application/startproject.cshtml
index b600f34ea..1db73d26b 100644
--- a/src/Web/Rsp.IrasPortal.Web/Views/Application/IrasIdCheck.cshtml
+++ b/src/Web/Rsp.IrasPortal.Web/Views/Application/startproject.cshtml
@@ -1,4 +1,4 @@
-﻿@model IrasIdCheckViewModel
+﻿@model IrasIdViewModel
 
 @{
 	ViewData["Title"] = "Project details";
@@ -11,10 +11,7 @@
 <div class="govuk-grid-row">
 	<div class="govuk-grid-column-two-thirds">
 		<form method="post">
-			@if (ViewData.ModelState["IrasId"]?.Errors.Count > 0)
-			{
-				<partial name="_ValidationSummary" model="(ViewData.ModelState, errorSummaryTitle)" />
-			}
+			<partial name="_ValidationSummary" model="(ViewData.ModelState, errorSummaryTitle)" />
 
 			<div class="govuk-form-group @(ViewData.ModelState["IrasId"]?.Errors.Count > 0 ? "govuk-form-group--error" : "")">
 				<label class="govuk-label" asp-for="IrasId">IRAS ID</label>
@@ -22,19 +19,7 @@
 				<div class="govuk-hint">
 					This is the 4 to 7 digit ID generated when you created your initial application
 				</div>
-
-				@if (ViewData.ModelState["IrasId"]?.Errors.Count > 0)
-				{
-					@foreach (var state in ViewData.ModelState)
-					{
-						foreach (var stateEntry in state.Value.Errors)
-						{
-							<span class="govuk-error-message">
-								<span class="govuk-visually-hidden">Error:</span> @stateEntry.ErrorMessage
-							</span>
-						}
-					}
-				}
+				<span class="govuk-error-message" asp-validation-for="IrasId"></span>
 
 				<input class="govuk-input govuk-input--width-10 @(ViewData.ModelState["IrasId"]?.Errors.Count > 0 ? "govuk-input--error" : "")"
 					   asp-for="IrasId"
@@ -42,7 +27,7 @@
 					   maxlength="7" />
 			</div>
 
-			<button class="govuk-button" data-module="govuk-button" type="submit" asp-route="app:IrasIdCheck">Save and continue</button>
+			<button class="govuk-button" data-module="govuk-button" type="submit" asp-route="app:startproject">Save and continue</button>
 		</form>
 	</div>
 </div>
diff --git a/src/Web/Rsp.IrasPortal.Web/Views/Questionnaire/Index.cshtml b/src/Web/Rsp.IrasPortal.Web/Views/Questionnaire/Index.cshtml
index b3eb00618..8e5731026 100644
--- a/src/Web/Rsp.IrasPortal.Web/Views/Questionnaire/Index.cshtml
+++ b/src/Web/Rsp.IrasPortal.Web/Views/Questionnaire/Index.cshtml
@@ -3,6 +3,7 @@
 @using Microsoft.FeatureManagement
 @using Rsp.IrasPortal.Application.Constants
 @using Rsp.IrasPortal.Application.DTOs
+@using Rsp.IrasPortal.Web.TagHelpers
 @model QuestionnaireViewModel
 @inject IFeatureManager featureManager
 
@@ -87,7 +88,7 @@
 
 @{
 	var navModel = string.IsNullOrEmpty(previousStage) ?
-						("app:irasidcheck", "Back", new Dictionary<string, string>()) :
+						("app:startproject", "Back", new Dictionary<string, string>()) :
 						("qnc:displayquestionnaire", "Back", new Dictionary<string, string>
 																																{
 							{ "categoryId", previousCategory },
@@ -165,12 +166,6 @@
 							}
 						}
 
-						@functions {
-						public string BuildAriaDescribedBy(string text)
-						{
-							return $"{text?.Replace(" ", "-")}-hint";
-						}
-					}
 						var parents = conditional.Length > 0 ? "data-parents=" + string.Join(',', rules.Select(rule => rule.ParentQuestionId)) : "";
 						var questionId = conditional.Length > 0 ? "data-questionId=" + question.QuestionId : "";
 
@@ -185,9 +180,9 @@
 									<div id="Questions[@index].AnswerText" class="@(conditional)govuk-form-group@(fieldHasErrors ? " govuk-form-group--error" : "")" @parents @questionId>
 
 										<div class="govuk-form-group">
-											<label class="govuk-label" aria-describedby="@BuildAriaDescribedBy(question.QuestionText)" for="@(question.QuestionId)_Text">@question.QuestionText</label>
+											<label class="govuk-label" for="@(question.QuestionId)_Text" aria-described-for="Questions[index].QuestionText">@question.QuestionText</label>
 
-											<div id="rule-hint" class="govuk-hint">
+											<div aria-id-for="Questions[index].QuestionText" class="govuk-hint">
 												@if (rules.Count > 0)
 												{
 													@foreach (var rule in rules)
@@ -263,8 +258,8 @@
 
 									<div id="Questions[@index].AnswerText" class="@(conditional)govuk-form-group@(fieldHasErrors ? " govuk-form-group--error" : "")" @parents @questionId>
 										<div class="govuk-form-group">
-											<label class="govuk-label" aria-describedby="@BuildAriaDescribedBy(question.QuestionText)" for="@(question.QuestionId)_Text">@question.QuestionText</label>
-											<div id="rule-hint" class="govuk-hint">
+											<label class="govuk-label" for="@(question.QuestionId)_Text" aria-described-for="Questions[index].QuestionText">@question.QuestionText</label>
+											<div aria-id-for="Questions[index].QuestionText" class="govuk-hint">
 												@if (rules.Count > 0)
 												{
 													@foreach (var rule in rules)
diff --git a/tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/Controllers/ApplicationControllerTests/IrasIdCheckTests.cs b/tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/Controllers/ApplicationControllerTests/StartProjectTests.cs
similarity index 76%
rename from tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/Controllers/ApplicationControllerTests/IrasIdCheckTests.cs
rename to tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/Controllers/ApplicationControllerTests/StartProjectTests.cs
index 32e4af5dd..bb9ef44cd 100644
--- a/tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/Controllers/ApplicationControllerTests/IrasIdCheckTests.cs
+++ b/tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/Controllers/ApplicationControllerTests/StartProjectTests.cs
@@ -12,9 +12,9 @@
 
 namespace Rsp.IrasPortal.UnitTests.Web.Controllers.ApplicationControllerTests;
 
-public class IrasIdCheckTests : TestServiceBase<ApplicationController>
+public class StartProjectTests : TestServiceBase<ApplicationController>
 {
-    public IrasIdCheckTests()
+    public StartProjectTests()
     {
         var mockSession = new Mock<ISession>();
 
@@ -30,20 +30,20 @@ public IrasIdCheckTests()
     }
 
     [Fact]
-    public async Task IrasIdCheck_ReturnsView_WithModel_WhenValidationFails()
+    public async Task StartProject_ReturnsView_WithModel_WhenValidationFails()
     {
         // Arrange
-        var model = new IrasIdCheckViewModel { IrasId = "1234" };
+        var model = new IrasIdViewModel { IrasId = "1234" };
         Mocker
-            .GetMock<IValidator<IrasIdCheckViewModel>>()
-            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdCheckViewModel>>(), default))
+            .GetMock<IValidator<IrasIdViewModel>>()
+            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdViewModel>>(), default))
             .ReturnsAsync(new ValidationResult(new List<ValidationFailure>
             {
                 new("IrasId", "Required")
             }));
 
         // Act
-        var result = await Sut.IrasIdCheck(model);
+        var result = await Sut.StartProject(model);
 
         // Assert
         result.ShouldBeOfType<ViewResult>();
@@ -51,14 +51,14 @@ public async Task IrasIdCheck_ReturnsView_WithModel_WhenValidationFails()
     }
 
     [Fact]
-    public async Task IrasIdCheck_ReturnsServiceError_IfGetApplicationsFails()
+    public async Task StartProject_ReturnsServiceError_IfGetApplicationsFails()
     {
         // Arrange
-        var model = new IrasIdCheckViewModel { IrasId = "1234" };
+        var model = new IrasIdViewModel { IrasId = "1234" };
 
         Mocker
-            .GetMock<IValidator<IrasIdCheckViewModel>>()
-            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdCheckViewModel>>(), default))
+            .GetMock<IValidator<IrasIdViewModel>>()
+            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdViewModel>>(), default))
             .ReturnsAsync(new ValidationResult());
 
         Mocker
@@ -70,25 +70,25 @@ public async Task IrasIdCheck_ReturnsServiceError_IfGetApplicationsFails()
             });
 
         // Act
-        var result = await Sut.IrasIdCheck(model);
+        var result = await Sut.StartProject(model);
 
         // Assert
         result.ShouldBeOfType<ViewResult>();
     }
 
     [Fact]
-    public async Task IrasIdCheck_ReturnsView_IfIrasIdAlreadyExists()
+    public async Task StartProject_ReturnsView_IfIrasIdAlreadyExists()
     {
         // Arrange
-        var model = new IrasIdCheckViewModel { IrasId = "1234" };
+        var model = new IrasIdViewModel { IrasId = "1234" };
         var existingApps = new List<IrasApplicationResponse>
         {
             new() { IrasId = 1234, ApplicationId = "a1", Title = "Test" }
         };
 
         Mocker
-            .GetMock<IValidator<IrasIdCheckViewModel>>()
-            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdCheckViewModel>>(), default))
+            .GetMock<IValidator<IrasIdViewModel>>()
+            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdViewModel>>(), default))
             .ReturnsAsync(new ValidationResult());
 
         Mocker
@@ -101,7 +101,7 @@ public async Task IrasIdCheck_ReturnsView_IfIrasIdAlreadyExists()
             });
 
         // Act
-        var result = await Sut.IrasIdCheck(model);
+        var result = await Sut.StartProject(model);
 
         // Assert
         result.ShouldBeOfType<ViewResult>();
@@ -109,14 +109,14 @@ public async Task IrasIdCheck_ReturnsView_IfIrasIdAlreadyExists()
     }
 
     [Fact]
-    public async Task IrasIdCheck_ReturnsServiceError_IfCreateApplicationFails()
+    public async Task StartProject_ReturnsServiceError_IfCreateApplicationFails()
     {
         // Arrange
-        var model = new IrasIdCheckViewModel { IrasId = "9999" };
+        var model = new IrasIdViewModel { IrasId = "9999" };
 
         Mocker
-            .GetMock<IValidator<IrasIdCheckViewModel>>()
-            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdCheckViewModel>>(), default))
+            .GetMock<IValidator<IrasIdViewModel>>()
+            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdViewModel>>(), default))
             .ReturnsAsync(new ValidationResult());
 
         Mocker
@@ -134,14 +134,14 @@ public async Task IrasIdCheck_ReturnsServiceError_IfCreateApplicationFails()
             .ReturnsAsync(new ServiceResponse<IrasApplicationResponse> { StatusCode = HttpStatusCode.OK });
 
         // Act
-        var result = await Sut.IrasIdCheck(model);
+        var result = await Sut.StartProject(model);
 
         // Assert
         result.ShouldBeOfType<ViewResult>();
     }
 
     [Fact]
-    public async Task IrasIdCheck_RedirectsToResume_IfEverythingSucceeds()
+    public async Task StartProject_RedirectsToResume_IfEverythingSucceeds()
     {
         // Arrange
         var mockSession = new Mock<ISession>();
@@ -151,12 +151,12 @@ public async Task IrasIdCheck_RedirectsToResume_IfEverythingSucceeds()
             Session = mockSession.Object
         };
 
-        var model = new IrasIdCheckViewModel { IrasId = "5678" };
+        var model = new IrasIdViewModel { IrasId = "5678" };
         var createdApp = new IrasApplicationResponse { ApplicationId = "abc", IrasId = 5678, Title = "Test" };
 
         Mocker
-            .GetMock<IValidator<IrasIdCheckViewModel>>()
-            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdCheckViewModel>>(), default))
+            .GetMock<IValidator<IrasIdViewModel>>()
+            .Setup(v => v.ValidateAsync(It.IsAny<ValidationContext<IrasIdViewModel>>(), default))
             .ReturnsAsync(new ValidationResult());
 
         Mocker
@@ -187,7 +187,7 @@ public async Task IrasIdCheck_RedirectsToResume_IfEverythingSucceeds()
             });
 
         // Act
-        var result = await Sut.IrasIdCheck(model);
+        var result = await Sut.StartProject(model);
 
         // Assert
         result.ShouldBeOfType<RedirectToActionResult>();
diff --git a/tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityTagHelperTests.cs b/tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityTagHelperTests.cs
new file mode 100644
index 000000000..ebefe3600
--- /dev/null
+++ b/tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityTagHelperTests.cs
@@ -0,0 +1,108 @@
+﻿using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Rsp.IrasPortal.Web.TagHelpers;
+
+namespace Rsp.IrasPortal.UnitTests.Web.TagHelpers;
+
+public class AccessibilityTagHelperTests : TestServiceBase
+{
+    [Fact]
+    public async Task Sets_AriaDescribedBy_When_DescribedFor_Is_Provided()
+    {
+        // Arrange
+        var metadataProvider = new EmptyModelMetadataProvider();
+        var metadata = metadataProvider.GetMetadataForType(typeof(string));
+
+        var tagHelper = new AccessibilityTagHelper
+        {
+            DescribedFor = new ModelExpression("QuestionText", new ModelExplorer(metadataProvider, metadata, "Short project title"))
+        };
+
+        var output = new TagHelperOutput("input",
+            new TagHelperAttributeList(),
+            (useCachedResult, encoder) =>
+            {
+                var tagHelperContent = new DefaultTagHelperContent();
+                return Task.FromResult<TagHelperContent>(tagHelperContent);
+            });
+
+        var context = new TagHelperContext(
+            new TagHelperAttributeList(),
+            new Dictionary<object, object?>(),
+            "test");
+
+        // Act
+        await tagHelper.ProcessAsync(context, output);
+
+        // Assert
+        output.Attributes.ShouldContain(a => a.Name == "aria-describedby");
+        output.Attributes["aria-describedby"].Value.ShouldBe("short-project-title-hint");
+    }
+
+    [Fact]
+    public async Task Sets_Id_When_IdFor_Is_Provided()
+    {
+        // Arrange
+        var metadataProvider = new EmptyModelMetadataProvider();
+        var metadata = metadataProvider.GetMetadataForType(typeof(string));
+
+        var tagHelper = new AccessibilityTagHelper
+        {
+            IdFor = new ModelExpression("QuestionText", new ModelExplorer(metadataProvider, metadata, "Planned end date"))
+        };
+
+        var output = new TagHelperOutput("div",
+            new TagHelperAttributeList(),
+            (useCachedResult, encoder) =>
+            {
+                var tagHelperContent = new DefaultTagHelperContent();
+                return Task.FromResult<TagHelperContent>(tagHelperContent);
+            });
+
+        var context = new TagHelperContext(
+            new TagHelperAttributeList(),
+            new Dictionary<object, object?>(),
+            "test");
+
+        // Act
+        await tagHelper.ProcessAsync(context, output);
+
+        // Assert
+        output.Attributes.ShouldContain(a => a.Name == "id");
+        output.Attributes["id"].Value.ShouldBe("planned-end-date-hint");
+    }
+
+    [Fact]
+    public async Task Does_Not_Set_Attributes_If_Model_Is_Empty()
+    {
+        // Arrange
+        var metadataProvider = new EmptyModelMetadataProvider();
+        var metadata = metadataProvider.GetMetadataForType(typeof(string));
+
+        var tagHelper = new AccessibilityTagHelper
+        {
+            DescribedFor = new ModelExpression("QuestionText", new ModelExplorer(metadataProvider, metadata, string.Empty))
+        };
+
+        var output = new TagHelperOutput("span",
+            new TagHelperAttributeList(),
+            (useCachedResult, encoder) =>
+            {
+                var tagHelperContent = new DefaultTagHelperContent();
+                return Task.FromResult<TagHelperContent>(tagHelperContent);
+            });
+
+        var context = new TagHelperContext(
+            new TagHelperAttributeList(),
+            new Dictionary<object, object?>(),
+            "test");
+
+        // Act
+        await tagHelper.ProcessAsync(context, output);
+
+        // Assert
+        output.Attributes.ShouldNotContain(a => a.Name == "aria-describedby");
+        output.Attributes.ShouldNotContain(a => a.Name == "id");
+    }
+}
\ No newline at end of file


Reviews:
user: login: dan-hulmston-hra
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:41:27Z
user: login: shahzad-hassan-hra
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:45:22Z
user: login: shahzad-hassan-hra
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:25:41Z
user: login: vinaylingappa-HRA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T09:34:45Z
user: login: vinaylingappa-HRA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T09:45:01Z
user: login: shahzad-hassan-hra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T10:11:30Z
user: login: vinaylingappa-HRA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:13:42Z
user: login: shahzad-hassan-hra
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T17:06:36Z

Review comments:
diff_hunk: @@ -0,0 +1,18 @@
+﻿using System.Text.RegularExpressions;
+
+namespace Rsp.IrasPortal.Web.TagHelpers;
+
+public static partial class AccessibilityHelper
+{
+    public static string BuildAriaDescribedBy(string? input)
+    {
+        if (string.IsNullOrWhiteSpace(input))
+            return string.Empty;
path: src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityHelper.cs
user: login: shahzad-hassan-hra
body: Please always use curly braces for if statements
```suggestion
        if (string.IsNullOrWhiteSpace(input))
        {
            return string.Empty;
         }
```
created_at: 2025-04-17T08:41:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,7 +180,7 @@
 									<div id="Questions[@index].AnswerText" class="@(conditional)govuk-form-group@(fieldHasErrors ? " govuk-form-group--error" : "")" @parents @questionId>
 
 										<div class="govuk-form-group">
-											<label class="govuk-label" aria-describedby="@BuildAriaDescribedBy(question.QuestionText)" for="@(question.QuestionId)_Text">@question.QuestionText</label>
+											<label class="govuk-label" aria-describedby="@AccessibilityHelper.BuildAriaDescribedBy(question.QuestionText)" for="@(question.QuestionId)_Text">@question.QuestionText</label>
path: src/Web/Rsp.IrasPortal.Web/Views/Questionnaire/Index.cshtml
user: login: shahzad-hassan-hra
body: You have created a `TagHelper`, you can define the tags it should use or apply to, rather than calling the method like that. That defats the purpose of TagHelpers.

See the `AuthTagHelper`
created_at: 2025-04-17T08:43:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+﻿using Rsp.IrasPortal.Web.TagHelpers;
+
+namespace Rsp.IrasPortal.UnitTests.Web.TagHelpers;
+
+public class AccessibilityHelperTests
+{
+    [Theory]
+    [InlineData("Planned end date", "planned-end-date-hint")]
+    [InlineData("Chief Investigator!", "chief-investigator-hint")]
+    [InlineData("Short project title*", "short-project-title-hint")]
+    [InlineData("Special_Chars_&_More", "special-chars---more-hint")]
+    [InlineData("Spaces   and tabs", "spaces---and-tabs-hint")]
+    [InlineData("MixedCASE123", "mixedcase123-hint")]
+    [InlineData("Title (UK)", "title--uk-hint")]
+    [InlineData("---Already-Hyphenated---", "already-hyphenated-hint")]
+    [InlineData("   ", "")]
+    [InlineData("", "")]
+    [InlineData(null, "")]
+    public void BuildAriaDescribedBy_ReturnsExpectedValue(string input, string expected)
+    {
+        // Act
+        var result = AccessibilityHelper.BuildAriaDescribedBy(input);
+
+        // Assert
+        Assert.Equal(expected, result);
+    }
+}
path: tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityHelperTests.cs
user: login: shahzad-hassan-hra
body: Once you upgrade to proper TagHelper, your test will change. See the tests for `AuthTagHelper`
created_at: 2025-04-17T08:45:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+﻿using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using System.Text.RegularExpressions;
path: src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityHelper.cs
user: login: shahzad-hassan-hra
body: I think your CodeMaid or Visual Studio settings are not configured properly. All System.* namespaces should appear at the top
created_at: 2025-04-22T22:48:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+﻿using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using System.Text.RegularExpressions;
+
+namespace Rsp.IrasPortal.Web.TagHelpers;
+
+[HtmlTargetElement(Attributes = AriaDescribedByAttributeName)]
+[HtmlTargetElement(Attributes = AriaIdAttributeName)]
+public class BuildAriaTagHelper : TagHelper
+{
+    private const string AriaDescribedByAttributeName = "aria-described-for";
+    private const string AriaIdAttributeName = "aria-id-for";
+
+    [HtmlAttributeName(AriaDescribedByAttributeName)]
+    public ModelExpression DescribedFor { get; set; } = null!;
+
+    [HtmlAttributeName(AriaIdAttributeName)]
+    public ModelExpression IdFor { get; set; } = null!;
+
+    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
+    {
+        await base.ProcessAsync(context, output);
+
+        var propertyName = (DescribedFor?.Model ?? IdFor?.Model) as string;
+        if (string.IsNullOrWhiteSpace(propertyName))
+        {
+            return;
+        }
+
+        var ariaDescribedBy = AccessibilityHelper.BuildAriaDescribedBy(propertyName);
+        if (!string.IsNullOrWhiteSpace(DescribedFor?.Name))
+        {
+            output.Attributes.SetAttribute("aria-describedby", ariaDescribedBy);
+        }
+
+        if (!string.IsNullOrWhiteSpace(IdFor?.Name))
+        {
+            output.Attributes.SetAttribute("id", ariaDescribedBy);
+        }
+    }
+}
+
+public static partial class AccessibilityHelper
path: src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityHelper.cs
user: login: shahzad-hassan-hra
body: Why is this a partial class? You don't need a separate class, make it a private static method for `BuildAriaTagHelper`.
created_at: 2025-04-22T22:52:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+﻿using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using System.Text.RegularExpressions;
+
+namespace Rsp.IrasPortal.Web.TagHelpers;
+
+[HtmlTargetElement(Attributes = AriaDescribedByAttributeName)]
+[HtmlTargetElement(Attributes = AriaIdAttributeName)]
+public class BuildAriaTagHelper : TagHelper
path: src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityHelper.cs
user: login: shahzad-hassan-hra
body: Class names shouldn't sound like a verb. `AriaTagHelper` or `AccessibilityTagHelper` would be good. Also, class name should be same as file name as a common convention.
created_at: 2025-04-22T22:54:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+﻿using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using System.Text.RegularExpressions;
+
+namespace Rsp.IrasPortal.Web.TagHelpers;
+
+[HtmlTargetElement(Attributes = AriaDescribedByAttributeName)]
+[HtmlTargetElement(Attributes = AriaIdAttributeName)]
+public class BuildAriaTagHelper : TagHelper
+{
+    private const string AriaDescribedByAttributeName = "aria-described-for";
+    private const string AriaIdAttributeName = "aria-id-for";
+
+    [HtmlAttributeName(AriaDescribedByAttributeName)]
+    public ModelExpression DescribedFor { get; set; } = null!;
+
+    [HtmlAttributeName(AriaIdAttributeName)]
+    public ModelExpression IdFor { get; set; } = null!;
+
+    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
+    {
+        await base.ProcessAsync(context, output);
+
+        var propertyName = (DescribedFor?.Model ?? IdFor?.Model) as string;
+        if (string.IsNullOrWhiteSpace(propertyName))
+        {
+            return;
+        }
+
+        var ariaDescribedBy = AccessibilityHelper.BuildAriaDescribedBy(propertyName);
+        if (!string.IsNullOrWhiteSpace(DescribedFor?.Name))
+        {
+            output.Attributes.SetAttribute("aria-describedby", ariaDescribedBy);
+        }
+
+        if (!string.IsNullOrWhiteSpace(IdFor?.Name))
+        {
+            output.Attributes.SetAttribute("id", ariaDescribedBy);
+        }
+    }
+}
path: src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityHelper.cs
user: login: shahzad-hassan-hra
body: I would suggest providing comments for the whole class, so that other Devs can understand when they come across this piece of code.
created_at: 2025-04-22T22:57:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,7 +3,7 @@
 
 namespace Rsp.IrasPortal.Web.Validators;
 
-public class IrasIdCheckValidator : AbstractValidator<IrasIdCheckViewModel>
+public class IrasIdCheckValidator : AbstractValidator<IrasIdViewModel>
path: src/Web/Rsp.IrasPortal.Web/Validators/IrasIdCheckValidator.cs
user: login: shahzad-hassan-hra
body: Class name should be `IrasIdViewModelValidator`
created_at: 2025-04-22T22:58:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,9 +180,9 @@
 									<div id="Questions[@index].AnswerText" class="@(conditional)govuk-form-group@(fieldHasErrors ? " govuk-form-group--error" : "")" @parents @questionId>
 
 										<div class="govuk-form-group">
-											<label class="govuk-label" aria-describedby="@BuildAriaDescribedBy(question.QuestionText)" for="@(question.QuestionId)_Text">@question.QuestionText</label>
+											<label class="govuk-label" for="@(question.QuestionId)_Text" aria-described-for="Questions[index].QuestionText">@question.QuestionText</label>
 
-											<div id="rule-hint" class="govuk-hint">
+											<div id="rule-hint" aria-id-for="Questions[index].QuestionText" class="govuk-hint">
path: src/Web/Rsp.IrasPortal.Web/Views/Questionnaire/Index.cshtml
user: login: shahzad-hassan-hra
body: perhaps you can remove the `id="rule-hint"` as it's generated by `aria-id-for` tag helper.
created_at: 2025-04-22T23:04:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,88 @@
+﻿using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Rsp.IrasPortal.Web.TagHelpers;
+
+namespace Rsp.IrasPortal.UnitTests.Web.TagHelpers;
+
+public class BuildAriaTagHelperTests : TestServiceBase
+{
+    private static async Task<TagHelperOutput> RunTagHelperAsync(string? describedForValue, string? idForValue)
path: tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityHelperTests.cs
user: login: shahzad-hassan-hra
body: Why do you need this? You can just call ProcessAsync method directly, you just need to set the properties required by the TagHelper.
created_at: 2025-04-22T23:25:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+﻿using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using System.Text.RegularExpressions;
path: src/Web/Rsp.IrasPortal.Web/TagHelpers/AccessibilityHelper.cs
user: login: vinaylingappa-HRA
body: I have manually rearranged this now. Later we can catch-up to check how this can be configured
created_at: 2025-04-23T09:34:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,88 @@
+﻿using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Rsp.IrasPortal.Web.TagHelpers;
+
+namespace Rsp.IrasPortal.UnitTests.Web.TagHelpers;
+
+public class BuildAriaTagHelperTests : TestServiceBase
+{
+    private static async Task<TagHelperOutput> RunTagHelperAsync(string? describedForValue, string? idForValue)
path: tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityHelperTests.cs
user: login: vinaylingappa-HRA
body: I feel this is more cleaner which internally calls the ProcessAsync. Let me know if this needs to be refactored.
created_at: 2025-04-23T09:45:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,88 @@
+﻿using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Rsp.IrasPortal.Web.TagHelpers;
+
+namespace Rsp.IrasPortal.UnitTests.Web.TagHelpers;
+
+public class BuildAriaTagHelperTests : TestServiceBase
+{
+    private static async Task<TagHelperOutput> RunTagHelperAsync(string? describedForValue, string? idForValue)
path: tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityHelperTests.cs
user: login: shahzad-hassan-hra
body: It's adding unnecessary complication. We are not testing the running of TagHelper, that is taken care by the framework itself. We only need to test what we have added, please see the tests for `AuthTagHelper`. You can just set the properties when newing up `AccessiblityTagHelper` and call the ProcessAsync directly.
created_at: 2025-04-23T10:11:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,88 @@
+﻿using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Rsp.IrasPortal.Web.TagHelpers;
+
+namespace Rsp.IrasPortal.UnitTests.Web.TagHelpers;
+
+public class BuildAriaTagHelperTests : TestServiceBase
+{
+    private static async Task<TagHelperOutput> RunTagHelperAsync(string? describedForValue, string? idForValue)
path: tests/UnitTests/Rsp.IrasPortal.UnitTests/Web/TagHelpers/AccessibilityHelperTests.cs
user: login: vinaylingappa-HRA
body: I have updated the test now
created_at: 2025-04-23T13:13:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-23T17:07:48Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=HealthResearchAuthority_rsp-iras-portal&pullRequest=47) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=HealthResearchAuthority_rsp-iras-portal&pullRequest=47&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=HealthResearchAuthority_rsp-iras-portal&pullRequest=47&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=HealthResearchAuthority_rsp-iras-portal&pullRequest=47&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [72.5% Coverage on New Code](https://sonarcloud.io/component_measures?id=HealthResearchAuthority_rsp-iras-portal&pullRequest=47&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=HealthResearchAuthority_rsp-iras-portal&pullRequest=47&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=HealthResearchAuthority_rsp-iras-portal&pullRequest=47)


author_association: NONE