From c1baba3bddc146794ad6ef26d99c1cb515570ad3 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 19:58:36 +1000 Subject: [PATCH 1/3] fix(a11y): accessibility fixes Brings the T2 2026 a11y work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit Co-authored-by: Maple Fox Co-authored-by: Kimsreng Co-authored-by: baohuy-tran Co-authored-by: Thirus224849242 Co-authored-by: Tan Tai Co-authored-by: Swyam Khare Co-authored-by: SanjanaBottu Co-authored-by: sakethsram888 Co-authored-by: Niethin Co-authored-by: Gaurav Myana Co-authored-by: Freddy Co-authored-by: Eloise Ridder-Strickland --- .../A11Y-D01-Accessibility-Baseline_Phase1.md | 358 ++++++++++++++++ docs/accessibility/AUTHORING.md | 86 ++++ docs/accessibility/REGRESSION.md | 78 ++++ docs/accessibility/REMEDIATION.md | 124 ++++++ eslint.config.js | 16 +- .../admin/states/users/users.component.html | 7 +- src/app/api/models/task-status-colors.spec.ts | 36 ++ src/app/api/models/task-status.ts | 38 +- src/app/app.component.accessibility.spec.ts | 98 +++++ .../feedback-template-editor.component.html | 6 +- .../learning-outcome-editor.component.html | 28 +- ...ut-doubtfire-modal-content.component.html} | 64 +-- .../about-doubtfire-modal.component.ts | 4 +- .../task-date-slider.component.html | 18 +- .../task-date-slider.component.spec.ts | 66 +++ .../project-progress-bar.component.html | 6 +- .../project-progress-bar.component.spec.ts | 25 +- .../scorm-player/scorm-player.component.html | 7 +- .../common/services/confetti.service.spec.ts | 68 ++- src/app/common/services/confetti.service.ts | 19 +- src/app/common/testing/accessibility.spec.ts | 46 ++ src/app/common/testing/accessibility.ts | 36 ++ .../accept-eula/accept-eula.component.html | 1 + .../states/home/home.accessibility.spec.ts | 108 +++++ src/app/home/states/home/home.component.scss | 57 +++ .../lti-dashboard.component.html | 4 +- .../states/dashboard/dashboard.tpl.html | 38 -- .../student-task-list.tpl.html | 98 ----- .../task-assessment-card.tpl.html | 67 --- .../task-due-card/task-due-card.tpl.html | 109 ----- .../task-ilos-card.accessibility.spec.ts | 74 ++++ .../task-status-card.component.html | 9 +- .../task-status-card.component.spec.ts | 73 +++- .../task-dashboard.accessibility.spec.ts | 87 ++++ .../task-dashboard.component.scss | 21 +- .../task-dashboard/task-dashboard.tpl.html | 130 ------ .../jplag/jplag-report-viewer.component.html | 1 + .../task-planner/task-planner.component.scss | 8 + .../staff-notes/staff-notes.component.html | 61 ++- .../staff-notes/staff-notes.component.scss | 35 ++ .../staff-notes/staff-notes.component.ts | 2 - .../tutor-discussion.component.ts | 1 - .../tutor-notes/tutor-notes.component.html | 63 ++- .../tutor-notes/tutor-notes.component.scss | 35 ++ .../tutor-notes/tutor-notes.component.spec.ts | 104 +++++ .../tutor-notes/tutor-notes.component.ts | 2 - .../states/sign-in/sign-in.component.html | 10 +- .../grade-task-modal.accessibility.spec.ts | 83 ++++ .../grade-task-modal.component.html | 22 +- .../grade-task-modal.component.ts | 2 + ...ask-comment-composer.accessibility.spec.ts | 109 +++++ .../comment-bubble-action.component.html | 25 +- .../pdf-image-comment.accessibility.spec.ts | 77 ++++ .../task-comments-viewer.component.html | 98 +++-- .../units/states/analytics/analytics.tpl.html | 23 - .../d2l-unit-details-form.component.html | 2 + .../unit-group-set-editor.component.html | 23 +- .../unit-group-set-editor.component.spec.ts | 89 ++++ .../student-campus-select.component.html | 6 +- .../student-tutorial-select.component.html | 7 +- .../task-definition-overseer.component.html | 15 +- ...sk-definition-prerequisites.component.html | 1 + .../moderation/moderation.component.html | 12 +- .../moderation/moderation.component.spec.ts | 69 +++ .../staff-task-list.component.html | 264 ++++++------ .../staff-task-list.component.scss | 53 ++- .../staff-task-list.component.spec.ts | 395 +++++++++++++++++- .../states/tasks/inbox/inbox.component.html | 71 ++-- .../states/tasks/inbox/inbox.component.scss | 15 +- ...inbox.mobile-actions.accessibility.spec.ts | 189 +++++++++ .../unit-task-list.accessibility.spec.ts | 146 +++++++ .../unit-task-list.component.scss | 6 +- 72 files changed, 3378 insertions(+), 856 deletions(-) create mode 100644 docs/A11Y-D01-Accessibility-Baseline_Phase1.md create mode 100644 docs/accessibility/AUTHORING.md create mode 100644 docs/accessibility/REGRESSION.md create mode 100644 docs/accessibility/REMEDIATION.md create mode 100644 src/app/api/models/task-status-colors.spec.ts create mode 100644 src/app/app.component.accessibility.spec.ts rename src/app/common/modals/about-doubtfire-modal/{about-doubtfire-modal-content.tpl.html => about-doubtfire-modal-content.component.html} (56%) create mode 100644 src/app/common/modals/date-change-modal/task-date-slider.component.spec.ts create mode 100644 src/app/common/testing/accessibility.spec.ts create mode 100644 src/app/common/testing/accessibility.ts create mode 100644 src/app/home/states/home/home.accessibility.spec.ts delete mode 100644 src/app/projects/states/dashboard/dashboard.tpl.html delete mode 100644 src/app/projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html delete mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.tpl.html delete mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.tpl.html create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-ilos-card/task-ilos-card.accessibility.spec.ts create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.accessibility.spec.ts delete mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html create mode 100644 src/app/projects/states/tutor-notes/tutor-notes.component.spec.ts create mode 100644 src/app/tasks/modals/grade-task-modal/grade-task-modal.accessibility.spec.ts create mode 100644 src/app/tasks/task-comment-composer/task-comment-composer.accessibility.spec.ts create mode 100644 src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.accessibility.spec.ts delete mode 100644 src/app/units/states/analytics/analytics.tpl.html create mode 100644 src/app/units/states/edit/directives/unit-group-set-editor/unit-group-set-editor.component.spec.ts create mode 100644 src/app/units/states/tasks/inbox/directives/moderation/moderation.component.spec.ts create mode 100644 src/app/units/states/tasks/inbox/inbox.mobile-actions.accessibility.spec.ts create mode 100644 src/app/units/task-viewer/directives/unit-task-list/unit-task-list.accessibility.spec.ts diff --git a/docs/A11Y-D01-Accessibility-Baseline_Phase1.md b/docs/A11Y-D01-Accessibility-Baseline_Phase1.md new file mode 100644 index 0000000000..cbb64fca52 --- /dev/null +++ b/docs/A11Y-D01-Accessibility-Baseline_Phase1.md @@ -0,0 +1,358 @@ +# OnTrack Accessibility Baseline - Phase 1 + +**Ticket:** A11Y-D01 - Define the OnTrack accessibility baseline and critical user journeys +**Branch:** `docs/accessibility-baseline` +**Scope:** Documentation and copy specification only. No production code, API behaviour or data flow is changed by this document. +**Source snapshot:** `11.0.x` at `d16f6201caff78082e5e83c540320dbee5871404`. Journey mappings below describe that revision; re-check them when the implementation changes. + +## 1. Purpose & Scope + +The document exists so that every accessibility audit and implementation ticket for OnTrack works from the same test scope, the same severity language, the same evidence format, and a shared understanding of what "done" means for this phase of work. It is written in plain language throughout, so that anyone on the team - _regardless of technical background_ - can read, apply, and rely on it without needing to interpret jargon or infer intent. + +### 1.1 What this document covers + +- Site-wide support for people with disabilities and neurodivergent users, across both technical accessibility (_keyboard, screen reader, semantics, etc._) and cognitive/neurodivergent usability (_plain language, predictable layout, minimal unnecessary complexity_). +- Two critical, end-to-end user journeys - one **student**, one **staff** - mapped against OnTrack's real routes and components, to give future audits a concrete, reproducible starting point rather than an abstract checklist. +- Desktop web, mobile browsers, and the installed standalone PWA defined by [`src/manifest.webmanifest`](../src/manifest.webmanifest). A narrow desktop viewport alone does not establish mobile-app support; use the device and assistive-technology passes in Section 5. + +### 1.2 What this document explicitly excludes + +**Dark mode** is a separate objective and is not covered here. Where this baseline discusses colour and contrast, it is testing OnTrack's existing colour scheme as it stands today, not preparing for or requiring a dark mode implementation. + +### 1.3 Relationship to other objectives and tickets + +- This baseline applies across all objectives - any team running an accessibility audit or building a new feature **should use the terminology, severity scale, and finding template defined here**. +- However, this does **not** make this objective the implementation owner for every feature's accessibility. Individual features and their tickets retain ownership of their own behaviour and their own accessible implementation. This document sets shared expectations; it does not centralise responsibility for meeting them. +- Reuse the existing [A11Y-V01 validation notes](A11Y-V01-validation-notes.md), merged in [PR #239](https://github.com/ontrack-features-t2-2026/doubtfire-web/pull/239), as dated evidence for its recorded build and environments. Those notes document a prior audit and outstanding independent review; this document defines the reusable baseline. Neither document proves that later changes or untested mobile/PWA combinations pass. + +### 1.4 Boundary on future usability + +No one participating in future usability testing or feedback sessions related to this work will be required to disclose a disability or diagnosis in order to take part. Testing methods and recruitment should be designed with this in mind from the outset. + +## 2. Target Standard + +OnTrack's accessibility target for this phase of work is [**WCAG 2.2 Level AA**](https://www.w3.org/TR/WCAG22/). + +This is a **target the team is working toward, not a certification claim**. Adopting this standard means it is the benchmark used to write test methods, judge findings, and prioritise fixes throughout this baseline and the audits that follow it. It does not mean OnTrack currently meets [WCAG 2.2 AA](https://www.w3.org/WAI/WCAG2A-Conformance), that any page has been certified as compliant, or that meeting this target removes the need for ongoing attention as the site changes. Progress toward this target should be understood as an improvement and guardrail program - measurable progress, without ever presenting the current state of the site as fully compliant. + +## 3. Critical User Journeys + +--- + +### 3.1 Student Journey + +Enter Unit - View Task - Submit Work - Find Feedback + +| Step | Route | Component | Role Required | Notes | +| ------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Enter unit / view task list** | `/projects/:projectId/dashboard` | `ProjectDashboardComponent` | Student (project owner) | Desktop uses a split-pane layout with a CDK drag divider. Below 640 CSS px, use the Overview and Tasks controls; verify keyboard, touch, and screen-reader access to each pane. | +| **View a task (incl. status)** | `/projects/:projectId/dashboard/:taskAbbreviation` | `TaskDashboardComponent`, `TaskStatusCardComponent`, `TaskAssessmentCardComponent` | Student; staff views depend on unit-role checks, with Mod Notes further restricted | Select a task, then Task Details, Task Sheet, or Your Submission. Task Details contains status and, for graded/rated tasks, Assessment Information. Check tab navigation, active-state announcements, and the phone Details control. | +| **Submit work** | N/A — modal opened from a submission action | `UploadSubmissionModalComponent` | Student (project owner) | Group rating (when applicable), upload, then comment. At least 25 trimmed characters are required for Need Help and for feedback/reupload on portfolio-only tasks. Check file selection without dragging, validation, disabled-submit explanation, success/error announcements, and focus return. | +| **Find feedback** | Same selected-task route | `TaskCommentsViewerComponent` in `ProjectDashboardComponent` | Student (project owner) | Read feedback in the comments sidebar, using Open task comments when collapsed below 1000 CSS px. Below 640 CSS px, use the top-level Feedback control. This is separate from the Task Details tab. Verify the correct task's comments, status events, and attachments remain reachable after switching panes. | + +### 3.2 Staff Journey + +Find Submission - Review - Give Feedback - Change Status + +| Step | Route | Component | Role Required | Notes | +| -------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Find a submission (Inbox)** | `/units/:unitId/tasks/inbox` (+ `/:studentId/:taskDefAbbr`) | `UnitTaskInboxStateComponent` (routeMode: inbox) | Tutor, Convenor, Admin, Auditor | Default landing page for a unit - shared component across all four modes below. | +| **Find a submission (Explorer)** | `/units/:unitId/tasks/definition` (+ variant) | same (routeMode: definition) | Tutor, Convenor, Admin, Auditor | Filter by task definition. | +| **Find a submission (Moderation)** | `/units/:unitId/tasks/moderation` (+ variant) | same (routeMode: moderation) | Tutor, Convenor, Admin, Auditor | Mentor moderation queue. | +| **Find a submission (Overflow)** | `/units/:unitId/tasks/overflow` (+ variant) | same (routeMode: overflow) | Tutor, Convenor, Admin, Auditor | Overdue queue. | +| **Claim an overflow task (when applicable)** | Same selected-task route; footer action | `TaskClaimComponent` in `FooterComponent` | Route whitelist above plus a unit staff role; claim state gates actions | Claim Task is shown for overflow or an already-claimed task. The message says the claim expires after 30 minutes of inactivity. Verify claimed/disabled states and success/error announcements. | +| **Review the submission** | Same selected-task route | `InboxDashboardComponent` | Route whitelist above; staff/Mod Notes have additional component restrictions | Select the submission/document tab and inspect the PDF viewer. On phones, also test Open current document and returning to the selected task. | +| **Give feedback** | Same selected-task route; comments panel | `TaskCommentsViewerComponent`, `TaskCommentComposerComponent` in `InboxComponent` | Authorised staff viewer for the selected task | Open task comments if collapsed; enter and send synthetic feedback. Verify editor naming, send/attachment controls, validation, and the saved comment from the student Feedback pane. | +| **Change task status** | Same selected-task route; footer action | `FooterComponent` calling `Task.updateTaskStatus()` | Route whitelist above; loading, claim ownership, and discussion requirements also gate actions | Use an available status action such as Resubmit, Discuss, or Complete. Check disabled reasons, confirmation dialogs where applicable, success/error announcements, and the resulting student status. In Moderation, use View Status Buttons when necessary. | +| **Grade the submission (when applicable)** | Modal opened during a status change | `GradeTaskModalComponent` via `Task.updateTaskStatus()` | Same action restrictions as the status change | For a graded or quality-rated task, a gradeable target status opens the grade/rating dialog. Check keyboard and screen-reader operation, submit/cancel, focus return, and persisted values after reload. | + +The route guard controls entry, not permission for every mutation; use appropriate synthetic unit-role fixtures and record API denials as well as UI restrictions. Student project access is resolved through the project route, not a Student-only whitelist on the dashboard. + +**Known phone coverage gap at this snapshot:** `InboxComponent` renders `f-footer` only in its desktop branch (600 CSS px and wider). The phone branch contains the document and comments views but no equivalent footer. Do not record claim, status-change, or grade completion on phones as passing without verifying an accessible alternative or a later fix. The journey is mapped here; successful execution on every device remains audit work. + +## 4. Accessibility Test Areas + +--- + +For each area: _what to check_, _how to check it_, and _what evidence to capture_. Where the journey mapping above already surfaced a specific known risk, it's referenced directly so testers know exactly where to start. These areas are the minimum journey checks, not an exhaustive WCAG conformance checklist. + +### 4.1 Keyboard + +Every interactive element (buttons, tabs, modals, form controls) must be reachable and operable using only a keyboard. + +- **Known risk**: The student dashboard's task-list resize divider (`ProjectDashboardComponent`) is currently mouse-drag only (CDK drag-drop) — check whether a keyboard-accessible alternative exists. +- **Method**: Navigate each journey step using Tab/Shift+Tab/Enter/Space/Arrow keys only, no mouse. +- **Evidence**: Screen recording of keyboard-only traversal, noting any unreachable or un-triggerable controls. + +### 4.2 Focus + +Focus should be visible and not obscured, and should move logically. A modal may contain focus while open, but must provide an operable exit. + +- **Known risk**: Modals (`GradeTaskModalComponent`, `UploadSubmissionModalComponent`, `BatchFeedbackWorkflowDialogComponent`) should trap focus while open and return it sensibly on close. +- **Method**: Open/close each modal and confirm focus placement before, during, and after; check focus order across multi-stage flows (e.g. the upload modal's group - details - comments stages). +- **Evidence**: Screen recording or annotated screenshots showing focus indicator position at each step. + +### 4.3 Screen Reader + +All content and state changes must be announced correctly (NVDA/VoiceOver, per the environment matrix in Section 5). + +- **Known risks**: Success messages shown via alert + snack bar (task claiming); tab switching in `TaskDashboardComponent`/`InboxDashboardComponent`; loading states (`inboxLoading` in the staff inbox); the redirect to `/unauthorised` when a role-restricted route is accessed without permission (`role-whitelist.guard.ts`) - confirm the redirect and resulting page are announced clearly, and that the brief loading check beforehand doesn't leave a screen reader user on a blank or ambiguous page. +- **Method**: Navigate each journey step with a screen reader active; confirm dynamic changes (loading, success/error, tab switches, redirects) are announced, not just visually shown. +- **Evidence**: screen recording with screen reader audio, or a transcript of announcements against expected announcements. + +### 4.4 Semantics + +Correct use of headings, landmarks, lists, buttons vs. links, and ARIA roles/attributes where native HTML isn't sufficient. + +- **Method**: Inspect the rendered DOM (browser dev tools or an automated tool) for each journey step. +- **Evidence**: Annotated DOM snapshot or automated scan report per page/step. + +### 4.5 Zoom / Text Resize + +Content must remain usable at 200% browser zoom and at increased OS/browser text-size settings, with no loss of content or function. + +- **Method**: Test each journey step at 200% zoom and at a large text-size setting. +- **Evidence**: Before/after screenshots at default and 200% zoom. + +### 4.6 Reflow + +For vertically scrolling content, test at **320 CSS px wide**, equivalent to a 1280 CSS px viewport at 400% browser zoom. Content and controls must remain available without scrolling in two dimensions. [WCAG 2.2 SC 1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) allows exceptions for content that needs a two-dimensional layout, such as some tables or diagrams; record the specific exception rather than exempting the whole page or its controls. + +- **Known risk**: Student pane switching and collapsed comments; staff actions missing from the phone branch (Section 3.2); PDF controls, modals, and the on-screen keyboard covering feedback controls. +- **Method**: Complete each journey at 320 CSS px and at each device viewport in Section 5, including 400% desktop zoom. Check around the staff 600px and student 640px layout boundaries, and the 1000px comments boundary. Test portrait and landscape, including with the on-screen keyboard open. +- **Evidence**: Screenshots or recordings with CSS viewport dimensions, zoom level, orientation, and any justified two-dimensional-content exception recorded. + +### 4.7 Contrast + +Text and meaningful UI elements must meet **WCAG 2.2 AA** contrast ratios (4.5:1 for normal text, 3:1 for large text/UI components) checked against whichever colour theme/mode is active (excluding dark mode itself, which is out of scope). + +- **Method**: Automated contrast check per page/step. +- **Evidence**: Contrast checker output per checked element. + +### 4.8 Colour + +Information must never be conveyed by colour alone (e.g. status icons/labels in `taskStatusData`, warning/overflow icons in the staff task list). + +- **Method**: Review each status/warning indicator and confirm an icon, label, or text equivalent accompanies any colour coding. +- **Evidence**: Screenshot with colour-blindness simulation applied (e.g. a simulator extension), or annotated screenshot noting the non-colour cue. + +### 4.9 Motion + +As a product expectation, non-essential animation should respect the OS/browser "reduce motion" setting. Also check applicable WCAG requirements for flashing and moving content; enabling reduced motion alone does not establish that those requirements pass. + +- **Method**: Enable "reduce motion" at the OS level and repeat each journey step, checking nothing becomes unusable. +- **Evidence**: Screen recording with reduce-motion enabled. + +### 4.10 Cognitive Clarity + +Plain language, predictable layout, clear error/validation messaging, and minimal unnecessary complexity. + +- **Known risk**: The upload submission modal's minimum comment length requirement (25+ characters) and its disabled-submit state - confirm the reason for disablement is clearly communicated, not just visually implied. +- Method: walk through each journey step as a first-time user would, noting any unclear instructions, ambiguous error states, or unexplained disabled controls. +- Evidence: written notes per step, plus screenshots of any unclear or ambiguous UI encountered. + +## 5. Test Environment Matrix + +--- + +The matrix below defines repeatable coverage. Record exact browser, OS, assistive-technology, and app-build versions; "latest" alone is not reproducible. Mark an unavailable combination **Not tested**, with an owner for follow-up, rather than inferring a pass from another device. + +### Browsers + +- Chrome (latest stable) - **Primary** +- Firefox (latest stable) +- Safari (latest stable, macOS) / Edge (latest stable, Windows) + +### Operating Systems + +- Windows (latest supported release) +- macOS (latest supported release) +- iOS and Android for the mobile-browser and installed-PWA passes below. Record any unavailable platform as a coverage gap. + +### Screen Readers + +- NVDA (Windows) - **Primary**, (paired with Chrome or Firefox) +- VoiceOver (macOS) - (paired with Safari) +- VoiceOver with Safari on iOS; TalkBack with Chrome on Android. Repeat the journeys in the installed standalone PWA on each available platform, using touch exploration and swipe navigation. +- Additional, as available: JAWS (Windows). + +### Viewports + +- Desktop: 1920×1080 or 1366×768 CSS px, plus 200% text/zoom and the 400% reflow pass described in Section 4.6. +- Reflow minimum: 320 CSS px wide, independent of whichever phone is available. +- Mobile: 390×844 CSS px as a repeatable browser fixture, plus the actual iOS/Android device dimensions in portrait and landscape. Responsive emulation complements physical-device testing; it does not replace it. + +### Mobile app / installed PWA + +- Run each journey in a normal mobile browser and after launching the installed OnTrack PWA. Confirm the same build is loaded; a service worker can serve an older bundle (see [service-worker guidance](service-worker.md)). +- Include launch/resume, returning from the file picker or document viewer, browser/device back navigation, safe-area insets, focus after pane changes, touch target usability, large text, and the on-screen keyboard. Verify the selected task survives these transitions and feedback/submission actions remain reachable. +- Record browser versus standalone display mode in every finding. A browser pass must not be reported as an installed-app pass. Document missing staff phone actions from Section 3.2 as an open issue until verified fixed. + +Each finding logged against the finding template (Section 7) should record which specific browser/OS/screen-reader/viewport combination it was found in, since accessibility issues can be combination-specific. + +## 6. Severity Scale (P0–P3) + +--- + +Each finding is assigned one severity level, based on a combination of four factors: + +- How much harm or exclusion it causes. +- Whether it blocks a task entirely. +- How often a user would hit it. +- Whether it affects a shared component used across many pages. + When factors point to different levels, use the **highest** applicable severity. + +### P0 — Blocking + +A user relying on assistive technology or an accessibility feature **cannot complete a core task at all** (e.g. cannot submit work, cannot find their grade, cannot navigate past a certain point). No workaround exists within the page. + +- **Example**: A modal traps keyboard focus with no way to close or continue. +- **Shared-component impact**: A shared component that blocks a core task with no accessible workaround is P0 across its affected views. Reuse alone does not turn a minor issue into a blocker. + +### P1 — Severely Degraded + +A core task is **technically possible but significantly harder, slower, or more frustrating** for a user of assistive technology or an accessibility feature - not fully blocked, but a serious barrier. + +- **Example**: A status change is made but not announced to screen reader users, forcing them to guess or re-check manually. +- **Example**: Content critical to a decision (e.g. feedback text) is present but not reachable in a logical reading/focus order. + +### P2 — Minor Barrier + +The task is completable without serious difficulty, but there's a **real, noticeable accessibility problem** - inconsistent focus indication, a contrast ratio just below AA, a redundant or confusing label. + +- **Example**: A warning icon relies on colour alone but a text label is present elsewhere on the same row (so the information isn't entirely lost, just less discoverable). + +### P3 — Cosmetic / Low Impact + +A **minor inconvenience** with negligible effect on task completion - small inconsistencies, non-critical polish issues, or edge cases affecting very few users or very rarely encountered states. + +- **Example**: a decorative element lacks `alt=""` but conveys no information. + +### Applying the scale + +- **Harm**: How seriously does this affect a real user's ability to use OnTrack independently and with dignity? +- **Task-blocking**: Does it stop a task outright, slow it down, or barely register? +- **Frequency**: Is this hit on every use of a page, or only in a rare edge case? +- **Shared-component impact**: Record all affected views and increase triage priority when an issue is widespread. Change severity only when the broader evidence meets that level's harm and task-blocking criteria. + +## 7. Finding Template + +--- + +Every accessibility finding logged against this baseline uses the same fields, in the same structure, regardless of who logs it or which journey it comes from. Do not add, remove, or rename fields per-ticket - the structure only stays reusable if it stays fixed. + +| Field | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Route** | The exact URL/path where the issue was found (e.g. `/units/:unitId/tasks/inbox`). Use the real path pattern from the app, not a paraphrase. | +| **Component** | The Angular **component name** responsible for the affected UI (e.g. `GradeTaskModalComponent`), if known. Helps whoever fixes the issue locate the code quickly. | +| **Steps** | The exact sequence of actions to reproduce the issue, written so someone unfamiliar with the finding could follow them without guessing (include role/account used, browser/OS/screen reader, and viewport - matching an entry from **Section 5**'s matrix). | +| **Expected Result** | What should happen, per **WCAG 2.2 AA** and/or this baseline's test-area guidance (**Section 4**). | +| **Actual Result** | What actually happens - described factually, without severity judgement built into the wording. | +| **Severity** | One of **P0–P3**, assigned per the criteria in **Section 6**. | +| **Evidence** | A screen recording, screenshot, or tool output (e.g. software generated report, contrast checker result) demonstrating the issue. Use demonstration accounts and sanitised data only (see **Section 8**). | +| **Owner** | The person or team responsible for triaging or fixing the finding. | +| **Re-test Status** | Not yet re-tested / Fixed - confirmed / Fixed - not confirmed / Still present / Won't fix (with reason). Updated after a fix is attempted, using the same environment the issue was originally found in wherever possible. | + +## 8. Security & Privacy Notes + +> **Use demonstration accounts and sanitised evidence only.** No real student, staff, or unit data should appear in any screenshot, recording, or written finding produced during audits against this baseline. + +> **Do not use real student assessment content in test-plan examples.** Any example task, submission, or feedback used to illustrate a journey or a finding must be fabricated or clearly marked as a demonstration. + +> **Hidden accessibility text is still data exposure.** Content added purely for assistive technology - `aria-label`, `alt` text, visually-hidden spans, and similar - must be checked for unauthorised information the same way visible content is. A hidden label is not a safe place to leak information that shouldn't be shown to the current viewer (e.g. another student's name, an internal-only note, a role-restricted detail). This applies equally to elements found during the journeys above, such as the role-gated Tutor Notes tab (`InboxDashboardComponent`, `TaskDashboardComponent`) - hidden text near those areas should be checked as carefully as the visible UI already is. + +## 9. Phase 1 — Definition of Done + +### 9.1 What Phase 1 explicitly does not mean + +- That any accessibility audit has actually been run - running the audits is separate, future work. +- That any component has been fixed, or that any specific page currently passes **WCAG 2.2 AA**. +- That OnTrack is accessibility-compliant or certified in any formal sense. **WCAG 2.2 AA** is adopted here as a **target to work toward**, not a claim being made about the current state of the site. + +### 9.2 Explicitly out of scope for Phase 1 + +(per this ticket): Running the audits, fixing flagged components, implementing dark mode, purchasing commercial accessibility tooling, and formal legal certification. These are future work, to be scoped as separate tickets once this baseline is in use. + +### 9.3 Known open items + +For whoever picks up the next piece of work. + +- The staff phone branch omits the footer used for claims, status changes, and grading (Section 3.2). Audit and resolve this separately before claiming end-to-end staff mobile support. +- Execute the desktop, mobile-browser, and installed-PWA matrix in Section 5 against the chosen integration build. The source mapping in this document is not evidence of a passing runtime audit. +- Re-test the observations and outstanding independent review in A11Y-V01 where relevant; retain their original evidence and build context rather than duplicating or silently declaring them resolved. + +## 10 Phase 1 (this baseline) is complete when + +| Requirement | Result | Evidence | +| --------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ----------- | +| The target standard and scope are written in plain language. | **Met** | Section 1 | +| The plan covers both technical accessibility and cognitive or neurodivergent usability. | **Met** | Section 1.1 | +| Dark mode is clearly excluded from implementation while contrast and colour use remain testable. | **Met** | Section 1.2 | +| At least one student journey and one staff journey are mapped from start to finish, with known device gaps recorded. | **Met (source mapping only)** | Section 3 | +| Every accessibility test area has a repeatable method and a defined evidence type. | **Met** | Section 4 | +| The test environment matrix exists that the team can actually reproduce. | **Met** | Section 5 | +| The severity scale distinguishes a blocked task from a minor inconvenience. | **Met** | Section 6 | +| The findings template exists and can be reused without editing its structure. | **Met** | Section 7 | +| Security and privacy expectations for future audit evidence are documented. | **Met** | Section 8 | +| The document states that Phase 1 is an improvement and guardrail program, not proof that the whole site is compliant. | **Met** | Section 9.1 | + +## 11. Known Codebase Notes + +--- + +### AngularJS + +- Angular migration in progress - legacy `.coffee` state files and orphaned `.tpl.html`/`.scss` files still exist alongside migrated `.component.ts` files (e.g. `dashboard.tpl`, `task-dashboard.tpl`). Not active code, but can be confusing when browsing. +- Route-vs-template mismatches to expect - old CoffeeScript state definitions may reference templates that no longer exist post-migration. + +### Confirmations + +- What initially looked like a role-guard inconsistency between the bare `/units/:unitId/tasks` path and `tasks/inbox`/`tasks/definition`/etc. is **not an inconsistency** - they are two different features sharing a URL prefix. The bare `tasks` path renders `TaskViewerStateComponent`, a task-_definition_ viewer (unit-administration-adjacent, correctly restricted to Convenor/Admin/Auditor). The `tasks/inbox` etc. paths render `UnitTaskInboxStateComponent`, which reviews individual student _submissions_ (correctly includes Tutor, since that's core marking work). +- `SelectedTaskService` is shared infrastructure between staff and student views - task-selection state isn't duplicated per role. + +### Status and feedback paths + +- Student submission processing calls `Task.processTaskStatusChange()` from `UploadSubmissionModalComponent`; the method belongs to the task model. +- Staff footer actions call `Task.updateTaskStatus()`, which may open `GradeTaskModalComponent` before persisting a gradeable status. Staff feedback uses the shared comments viewer and composer, while student grade/quality summaries appear in `TaskAssessmentCardComponent`. + +## 12 Coverage + +--- + +### 12.1 Files + +| | | +| -------------------------------------- | ------------------------------------------------------------------------------------ | +| `unit-task-inbox-state.component.ts` | Confirmed the shared staff inbox/explorer/moderation/overflow component | +| `app.routes.ts` | The authoritative routing file - confirmed all staff route paths | +| `inbox-dashboard.component.ts` | Confirmed the read-only submission viewer | +| `inbox-dashboard.component.html` | Confirmed no grading action lived there | +| `staff-task-list.component.ts` | Confirmed task selection, keyboard shortcuts, filtering | +| `task-claim.component.ts` | Confirmed the claim action | +| `task-status.ts` | Confirmed the status vocabulary | +| `grade-task-modal.component.ts` | Confirmed the actual grading action | +| `project-dashboard.component.ts` | Confirmed the student split-pane layout | +| `task-dashboard.component.ts` | Confirmed task tabs and staff-view restrictions; comments are outside this component | +| `upload-submission-modal.component.ts` | Confirmed the student submission action | +| `role-whitelist.guard.ts` | Confirmed guard redirect behaviour | +| `task-viewer-state.component.ts` | Resolved the apparent role-guard inconsistency | +| `definitions.coffee` | Legacy staff route definition (superseded) | + +The completed journey mapping also traces these active sources (paths are relative to this document): + +- [Project dashboard template](../src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html): phone Feedback control and desktop comments sidebar. +- [Staff inbox template](../src/app/units/states/tasks/inbox/inbox.component.html): document/comments views and the desktop-only footer placement. +- [Footer template](../src/app/common/footer/footer.component.html) and [component](../src/app/common/footer/footer.component.ts): claim visibility, status actions, and their enabling conditions. +- [Task model](../src/app/api/models/task.ts): `updateTaskStatus()`, grade-dialog dispatch, persistence, and status-change processing. +- [Comments viewer](../src/app/tasks/task-comments-viewer/task-comments-viewer.component.html) and [composer](../src/app/tasks/task-comment-composer/task-comment-composer.component.ts): feedback display and submission. +- [Assessment card](../src/app/projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.component.html): student grade and quality-point display. + +### 12.2 Paths + +| | | +| --------------------------------------- | ----------------------------------------------------------------- | +| `units/states/tasks/inbox/` | Revealed the Angular migration | +| `units/states/tasks/inbox/directives/` | Revealed inbox-dashboard, moderation, staff-task-list, task-claim | +| `projects/states/dashboard/` | Revealed directives, dashboard.tpl, selected-task.service | +| `projects/states/dashboard/directives/` | Revealed progress-dashboard, student-task-list, task-dashboard | +| `task-dashboard/` | Revealed `task-dashboard.component.ts` plus legacy files | diff --git a/docs/accessibility/AUTHORING.md b/docs/accessibility/AUTHORING.md new file mode 100644 index 0000000000..4bb7bede27 --- /dev/null +++ b/docs/accessibility/AUTHORING.md @@ -0,0 +1,86 @@ +# Accessibility contribution guide + +Use this guide for frontend changes and reviews. WCAG 2.2 AA is the improvement target, not a claim of compliance. Start with the [merged A11Y-D01 baseline](../A11Y-D01-Accessibility-Baseline_Phase1.md), the [manual regression pack](REGRESSION.md), and the [remediation and handover register](REMEDIATION.md). The register distinguishes merged code, changes awaiting review, and checks still needing a person. + +For ticket acceptance and reviewer handover, follow the [closure guide](CLOSURE.md) and [run record](RUN-RECORD.md). The [A01 audit](evidence/a01/A01-LINT-AUDIT.md) records the main-branch findings and the fixed-stack scan separately. + +## Controls and structure + +- Use ` - diff --git a/src/app/common/learning-outcome-editor/learning-outcome-editor.component.html b/src/app/common/learning-outcome-editor/learning-outcome-editor.component.html index c8c668aabe..1df02c8e2e 100644 --- a/src/app/common/learning-outcome-editor/learning-outcome-editor.component.html +++ b/src/app/common/learning-outcome-editor/learning-outcome-editor.component.html @@ -96,20 +96,36 @@ @if (abbreviationPrefix !== 'GLO') { - - - } - - - diff --git a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.tpl.html b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.html similarity index 56% rename from src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.tpl.html rename to src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.html index 4d98486b5f..e129f420e4 100644 --- a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.tpl.html +++ b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal-content.component.html @@ -1,11 +1,11 @@
- OnTrack logo + OnTrack logo

- {{data.externalName}} + {{ data.externalName }} Version 6 @@ -18,64 +18,66 @@

Lead Contribut class="mx-auto flex w-11/12 list-none flex-wrap justify-center gap-y-8 p-0 sm:w-5/6 sm:gap-x-10 sm:py-12" > @for (person of data.mainContributors; track person) { -
  • - - -
    {{person.name}}
    -
    @{{person.login}}
    -
    -
  • +
  • + + +
    {{ person.name }}
    +
    @{{ person.login }}
    +
    +
  • }

    Contributors

    - - + - - + + - - + + - - + + - - + + - - + + - - + +
    Contributor + Contributor - @{{element.login}} + @{{ + element.login + }} Contributions{{element.totalContributions}}Contributions{{ element.totalContributions }} API{{element.apiContributions}}API{{ element.apiContributions }} Web{{element.webContributions}}Web{{ element.webContributions }} Deploy{{element.deployContributions}}Deploy{{ element.deployContributions }} doubtfire.io{{element.ioContributions}}doubtfire.io{{ element.ioContributions }}

    © 2012–2023. Made in Melbourne.

    @@ -92,5 +94,5 @@

    Contributors - + diff --git a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts index e0cdf53f73..123accbbb9 100644 --- a/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts +++ b/src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts @@ -11,7 +11,7 @@ import {GithubProfile} from './github-profile'; @Component({ selector: 'about-doubtfire-dialog', - templateUrl: 'about-doubtfire-modal-content.tpl.html', + templateUrl: 'about-doubtfire-modal-content.component.html', changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) @@ -35,7 +35,7 @@ export class AboutDoubtfireModalContent { /** * The about doubtfire modal service - used to create and show the modal */ -// eslint-disable-next-line max-classes-per-file + @Injectable() export class AboutDoubtfireModal { private loaded: boolean; diff --git a/src/app/common/modals/date-change-modal/task-date-slider.component.html b/src/app/common/modals/date-change-modal/task-date-slider.component.html index 3471580e68..6bbc0a178d 100644 --- a/src/app/common/modals/date-change-modal/task-date-slider.component.html +++ b/src/app/common/modals/date-change-modal/task-date-slider.component.html @@ -1,22 +1,24 @@
    @if (showTaskAbbr && editMode) { - + {{ task.definition.name }} }
    @if (showTaskAbbr) { - + {{ task.definition.abbreviation }}: } @else { - + } - + {{ task.localDueDateString() }} @if (task.unit.allowFlexibleDates) {
    diff --git a/src/app/common/modals/date-change-modal/task-date-slider.component.spec.ts b/src/app/common/modals/date-change-modal/task-date-slider.component.spec.ts new file mode 100644 index 0000000000..73e68acf2a --- /dev/null +++ b/src/app/common/modals/date-change-modal/task-date-slider.component.spec.ts @@ -0,0 +1,66 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatSliderModule} from '@angular/material/slider'; +import {provideRouter} from '@angular/router'; +import {Task} from 'src/app/api/models/task'; +import {AlertService} from '../../services/alert.service'; +import {ConfirmationModalService} from '../confirmation-modal/confirmation-modal.service'; +import {TaskDateSliderComponent} from './task-date-slider.component'; + +describe('TaskDateSliderComponent labels', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TaskDateSliderComponent], + imports: [FormsModule, MatSliderModule], + providers: [ + provideRouter([]), + {provide: AlertService, useValue: {}}, + {provide: ConfirmationModalService, useValue: {}}, + ], + }).compileComponents(); + fixture = TestBed.createComponent(TaskDateSliderComponent); + fixture.componentRef.setInput('task', { + id: 41, + definition: {name: 'First task', abbreviation: '1.1P'}, + unit: {totalWeeks: 12, allowFlexibleDates: false}, + project: {specConDays: 0}, + dueWeek: 2, + localDueDateString: () => '20 Sep', + localDueDate: () => new Date('2026-09-20'), + localDeadlineDate: () => new Date('2026-09-30'), + } as unknown as Task); + fixture.detectChanges(); + }); + + it('associates Complete By with the native slider thumb', () => { + const label: HTMLLabelElement = fixture.nativeElement.querySelector('label'); + const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); + expect(label.textContent).toContain('Complete By'); + expect(label.control).toBe(input); + expect(input.disabled).toBe(true); + expect(input.id).toBe('task-due-date-41'); + }); + + it('keeps the thumb named when the task abbreviation replaces the label', async () => { + fixture.componentRef.setInput('showTaskAbbr', true); + fixture.detectChanges(); + fixture.componentInstance.editMode = true; + fixture.detectChanges(); + await fixture.whenStable(); + const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); + expect(input.getAttribute('aria-label')).toBe('Complete by for 1.1P'); + expect(input.disabled).toBe(false); + expect(fixture.nativeElement.querySelector('label')).toBeNull(); + }); + + it('renders deadline warnings as text rather than control labels', () => { + fixture.componentInstance.editMode = true; + fixture.componentInstance.task.localDeadlineDate = () => new Date('2026-09-19'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('p')?.textContent).toContain('Warning:'); + expect(fixture.nativeElement.querySelectorAll('label')).toHaveLength(1); + }); +}); diff --git a/src/app/common/project-progress-bar/project-progress-bar.component.html b/src/app/common/project-progress-bar/project-progress-bar.component.html index 4922b140fd..37aab5bd17 100644 --- a/src/app/common/project-progress-bar/project-progress-bar.component.html +++ b/src/app/common/project-progress-bar/project-progress-bar.component.html @@ -1 +1,5 @@ - + diff --git a/src/app/common/project-progress-bar/project-progress-bar.component.spec.ts b/src/app/common/project-progress-bar/project-progress-bar.component.spec.ts index 0bd437cb91..2edaf426e4 100644 --- a/src/app/common/project-progress-bar/project-progress-bar.component.spec.ts +++ b/src/app/common/project-progress-bar/project-progress-bar.component.spec.ts @@ -1,6 +1,7 @@ import {beforeEach, describe, expect, it} from 'vitest'; -import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatProgressBarModule} from '@angular/material/progress-bar'; +import {expectAccessible} from '../testing/accessibility'; import {ProjectProgressBarComponent} from './project-progress-bar.component'; describe('ProjectProgressBarComponent', () => { @@ -10,10 +11,8 @@ describe('ProjectProgressBarComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ProjectProgressBarComponent], - schemas: [NO_ERRORS_SCHEMA], - }) - .overrideComponent(ProjectProgressBarComponent, {set: {template: ''}}) - .compileComponents(); + imports: [MatProgressBarModule], + }).compileComponents(); }); beforeEach(() => { @@ -24,4 +23,20 @@ describe('ProjectProgressBarComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('exposes the project progress name and current percentage', async () => { + fixture.componentRef.setInput('progress', [ + {value: 0}, + {value: 0}, + {value: 0}, + {value: 0}, + {value: 42}, + ]); + fixture.detectChanges(); + const bar = fixture.nativeElement.querySelector('[role="progressbar"]'); + expect(bar.getAttribute('aria-label')).toBe('Project progress towards target grade'); + expect(bar.getAttribute('aria-valuenow')).toBe('42'); + await fixture.whenStable(); + await expectAccessible(fixture.nativeElement); + }); }); diff --git a/src/app/common/scorm-player/scorm-player.component.html b/src/app/common/scorm-player/scorm-player.component.html index 0e36d95f4f..a931738a71 100644 --- a/src/app/common/scorm-player/scorm-player.component.html +++ b/src/app/common/scorm-player/scorm-player.component.html @@ -1,3 +1,8 @@ @if (iframeSrc) { - + } diff --git a/src/app/common/services/confetti.service.spec.ts b/src/app/common/services/confetti.service.spec.ts index 5adbef2d05..935d22e4f7 100644 --- a/src/app/common/services/confetti.service.spec.ts +++ b/src/app/common/services/confetti.service.spec.ts @@ -1,16 +1,74 @@ -import {beforeEach, describe, expect, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {TestBed} from '@angular/core/testing'; -import {ConfettiService} from './confetti.service'; +import {CONFETTI, ConfettiService} from './confetti.service'; describe('ConfettiService', () => { let service: ConfettiService; + let originalMatchMedia: PropertyDescriptor | undefined; + const confetti = vi.fn(); + + function setMatchMedia(value: typeof window.matchMedia | undefined): void { + Object.defineProperty(window, 'matchMedia', {configurable: true, writable: true, value}); + } beforeEach(() => { - TestBed.configureTestingModule({}); + originalMatchMedia = Object.getOwnPropertyDescriptor(window, 'matchMedia'); + TestBed.configureTestingModule({providers: [{provide: CONFETTI, useValue: confetti}]}); service = TestBed.inject(ConfettiService); + confetti.mockClear(); + }); + + afterEach(() => { + if (originalMatchMedia) { + Object.defineProperty(window, 'matchMedia', originalMatchMedia); + } else { + delete (window as unknown as {matchMedia?: unknown}).matchMedia; + } + }); + + it('does not launch particles when reduced motion is requested', () => { + const matchMedia = vi.fn().mockReturnValue({matches: true} as MediaQueryList); + setMatchMedia(matchMedia); + + service.canon(0.95, 0.05, 210); + + expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); + expect(confetti).not.toHaveBeenCalled(); + }); + + it('keeps the celebration when reduced motion is not requested', () => { + setMatchMedia(vi.fn().mockReturnValue({matches: false} as MediaQueryList)); + + service.canon(0.95, 0.05, 210); + + expect(confetti).toHaveBeenCalledExactlyOnceWith({ + angle: 210, + spread: 80, + particleCount: 100, + origin: {x: 0.95, y: 0.05}, + }); + }); + + it('checks the current preference for every celebration', () => { + setMatchMedia( + vi.fn().mockReturnValueOnce({matches: false}).mockReturnValueOnce({matches: true}), + ); + + service.canon(); + service.canon(); + + expect(confetti).toHaveBeenCalledTimes(1); }); - it('should be created', () => { - expect(service).toBeTruthy(); + it('supports environments without matchMedia', () => { + setMatchMedia(undefined); + + expect(() => service.canon()).not.toThrow(); + expect(confetti).toHaveBeenCalledExactlyOnceWith({ + angle: 210, + spread: 80, + particleCount: 100, + origin: {x: 0, y: 0}, + }); }); }); diff --git a/src/app/common/services/confetti.service.ts b/src/app/common/services/confetti.service.ts index 92a51c8ad2..ab770a27da 100644 --- a/src/app/common/services/confetti.service.ts +++ b/src/app/common/services/confetti.service.ts @@ -1,12 +1,27 @@ import confetti from 'canvas-confetti'; -import {Injectable} from '@angular/core'; +import {Injectable, InjectionToken, inject} from '@angular/core'; + +export const CONFETTI: InjectionToken = new InjectionToken('Confetti', { + providedIn: 'root', + factory: () => confetti, +}); @Injectable({ providedIn: 'root', }) export class ConfettiService { + private readonly launch = inject(CONFETTI); + public canon(x: number = 0, y: number = 0, angle = 210): void { - confetti({ + if ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + return; + } + + this.launch({ angle: angle, spread: 80, particleCount: 100, diff --git a/src/app/common/testing/accessibility.spec.ts b/src/app/common/testing/accessibility.spec.ts new file mode 100644 index 0000000000..c3f5122fc3 --- /dev/null +++ b/src/app/common/testing/accessibility.spec.ts @@ -0,0 +1,46 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {expectAccessible} from './accessibility'; + +describe('expectAccessible', () => { + let fixture: HTMLElement; + + beforeEach(() => { + fixture = document.createElement('main'); + document.body.append(fixture); + }); + + afterEach(() => fixture.remove()); + + it('accepts a named button in the rendered fixture', async () => { + fixture.innerHTML = ''; + const result = await expectAccessible(fixture); + expect(result.violations).toEqual([]); + expect(result.passes.some((rule) => rule.id === 'button-name')).toBe(true); + }); + + it('fails the check for a deliberately unnamed button', async () => { + fixture.innerHTML = ''; + await expect(expectAccessible(fixture)).rejects.toThrow( + /button-name \((serious|critical)\)[\s\S]*unnamed-action/, + ); + }); + + it('does not keep a failed fixture in a baseline after the defect is fixed', async () => { + fixture.innerHTML = ''; + await expect(expectAccessible(fixture)).rejects.toThrow('button-name'); + fixture.querySelector('button').setAttribute('aria-label', 'Save changes'); + await expect(expectAccessible(fixture)).resolves.toMatchObject({violations: []}); + }); + + it('keeps native label association checks enabled', async () => { + fixture.innerHTML = ''; + await expect(expectAccessible(fixture)).rejects.toThrow(/label \((serious|critical)\)/); + fixture.insertAdjacentHTML('afterbegin', ''); + await expect(expectAccessible(fixture)).resolves.toMatchObject({violations: []}); + }); + + it('rejects detached fixtures instead of reporting an empty scan as a pass', async () => { + fixture.remove(); + await expect(expectAccessible(fixture)).rejects.toThrow('attached to the document'); + }); +}); diff --git a/src/app/common/testing/accessibility.ts b/src/app/common/testing/accessibility.ts new file mode 100644 index 0000000000..d2f9b00dbb --- /dev/null +++ b/src/app/common/testing/accessibility.ts @@ -0,0 +1,36 @@ +import axe, {AxeResults} from 'axe-core'; + +/** + * Check a rendered, attached fixture after Angular's fixture.whenStable(). + * Await this helper with real timers; axe runs asynchronously. + * + * This is a DOM regression check, not a complete WCAG audit. jsdom cannot + * measure layout or colour contrast, so color-contrast is the only disabled + * rule. Keep contrast-math tests and manual browser checks alongside it. + * https://github.com/dequelabs/axe-core#supported-browsers + * + * Incomplete results need manual review and remain available in the return + * value. No violation baseline or severity filter hides newly detected issues. + */ +export async function expectAccessible(element: HTMLElement): Promise { + if (!element?.isConnected) { + throw new Error('Accessibility checks require a rendered element attached to the document.'); + } + + const results = await axe.run(element, { + // Inspect local fixture styles only; never fetch external stylesheets/media. + preload: false, + rules: {'color-contrast': {enabled: false}}, + }); + + if (results.violations.length > 0) { + const failures = results.violations.map((violation) => { + const targets = violation.nodes.map((node) => JSON.stringify(node.target)).join(', '); + return `${violation.id} (${violation.impact}): ${violation.help}\n Targets: ${targets}`; + }); + // Report selectors and rule names without logging fixture HTML or user data. + throw new Error(`Accessibility violations:\n${failures.join('\n')}`); + } + + return results; +} diff --git a/src/app/eula/accept-eula/accept-eula.component.html b/src/app/eula/accept-eula/accept-eula.component.html index 5de43cf604..39e58a2e47 100644 --- a/src/app/eula/accept-eula/accept-eula.component.html +++ b/src/app/eula/accept-eula/accept-eula.component.html @@ -8,6 +8,7 @@

    End User License Agreements

    people
    diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html deleted file mode 100644 index 5e666e5409..0000000000 --- a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html +++ /dev/null @@ -1,98 +0,0 @@ -
    -
    - -
    -
      -
    • - - -
    • - -
    • -
      -

      {{task.definition.name}}

      -

      - - {{task.definition.abbreviation}} - {{gradeNames[task.definition.targetGrade]}} Task - {{task.timeToStart()}} {{task.timeToDue()}} -

      -
      - -
      - -
      - - {{task.numNewComments}} - - - - -
      -
      - - {{task.gradeDesc()}} - - - {{task.qualityQts}}⁄{{task.definition.maxQualityPts}} - - - - - - - - - ! - -
      -
      - -
    • - -
    • - - -
    • -
    • - No tasks to display. -
    • -
    -
    diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.tpl.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.tpl.html deleted file mode 100644 index 0aa6c58092..0000000000 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.tpl.html +++ /dev/null @@ -1,67 +0,0 @@ -
    -
    -

    Assessment Information

    -
    -
    -
    -
    This task {{assessmentCards.hasBeenGraded ? 'has been' : 'will be'}} assigned a grade
    -
    -

    - This task will be graded against a grade standard. Your work will be assessed and assigned - a grade according to a Pass, Credit, Distinction or High Distinction standard. -

    -
    -
    Advice for achieving a {{task.project.targetGradeWord}}
    -

    - As you are attempting to achieve a {{task.project.targetGradeWord}} in this unit, you - should attempt to achieve a {{task.project.targetGradeWord}} grade on - this task. Ask your tutor to find out more on what they are looking for when they are - assessing this work to a specific grade. -

    -
    -
    - -
    - Your tutor has marked you on this task to a {{task.gradeWord}} standard. -
    - -
    - -
    -
    -
    - - This task will be assessed on a scale to {{task.definition.maxQualityPts}} - - This task has been assessed for quality -
    -

    - This task will be graded against a quality scale from - 0 to {{task.definition.maxQualityPts}}. Your work will assessed and - assigned a star rating based on the quality of your submission. -

    -
    - - -

    - You have been awarded out - {{task.qualityPts}} of {{task.definition.maxQualityPts}} - avaliable points for this task. -

    -
    -
    - -
    - -
    - diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.tpl.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.tpl.html deleted file mode 100644 index a4ea4b1999..0000000000 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.tpl.html +++ /dev/null @@ -1,109 +0,0 @@ -
    - -
    - -
    -
    -

    Aim To Complete Soon - Due in {{task.timeUntilDueDateDescription()}}

    -
    -
    - -

    - This task's due date is {{task.localDueDateString()}}. You should aim to - complete this task before then to keep your progress on track. -

    -
    -
    - -

    - This task's due date is {{task.localDueDateString()}}. Make sure to - discuss this task with your tutor as soon as possible. -

    -

    - Tasks are only considered Completed once your tutor has - Discussed your work with you. -

    -
    -
    -
    - -
    -
    -
    -

    Past Due Date By {{task.timePastDueDateDescription()}}

    -
    -
    - -

    - You should have completed this task by {{task.localDueDateString()}}. Try - and finish it as soon as possible to avoid falling behind. As you will submit this task - after the deadline for feedback, it will not be reviewed by a tutor and it is now your - sole responsibility to ensure that this submission meets the required standard. The task - will be assessed as part of the portfolio. -

    -

    - Aim to submit future tasks before the deadline to make good use of the opportunity to - receive feedback on your work and your tutor will work with you to make sure that your - submission meets all the requirements. -

    -
    -
    - -

    - You should have completed this task by {{task.localDueDateString()}}. - Make sure to discuss this task with your tutor as soon as possible. If this task remains - on this state for an extended period, it will be marked as Time Exceeded. -

    -

    - Tasks are only considered completed once your tutor has - discussed your work with you. -

    -
    -
    -
    - -
    -
    -
    -

    Passed Due Date By {{task.timePastDueDateDescription()}}

    -
    -
    - -

    - You should have completed this task by {{task.localDueDateString()}}. - This task is now past the deadline and will be marked as Time Exceeded when - submitted. You should consult with the unit assessment details to determine the impact of - failing to complete this task within the allocated time. -

    -
    -
    - -

    - You should have completed this task by {{task.localDueDateString()}}. - Make sure to discuss this task with your tutor as soon as possible. -

    -

    - Tasks are only considered Completed once it demonstrates the required - standard, and it is discussed with your tutor. -

    -
    -
    -
    -
    - -
    -
    -

    Wait for Tutor Feedback

    -
    -
    -

    - You have submitted this task and should now wait for feedback from your tutor. - Do not re-upload new files at this time as the status will be changed to - Time Exceeded. -

    -
    -
    diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-ilos-card/task-ilos-card.accessibility.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-ilos-card/task-ilos-card.accessibility.spec.ts new file mode 100644 index 0000000000..8279811092 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-ilos-card/task-ilos-card.accessibility.spec.ts @@ -0,0 +1,74 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {MatCardModule} from '@angular/material/card'; +import {MatChipsModule} from '@angular/material/chips'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {LearningOutcome} from 'src/app/api/models/learning-outcome'; +import {expectAccessible} from 'src/app/common/testing/accessibility'; +import {TaskIlosCardComponent} from './task-ilos-card.component'; + +describe('Task learning outcomes rendered accessibility', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TaskIlosCardComponent], + imports: [MatCardModule, MatChipsModule, MatTooltipModule], + }).compileComponents(); + }); + + it('presents linked outcomes as descriptive list items without grid roles or tab stops', async () => { + const fixture = TestBed.createComponent(TaskIlosCardComponent); + const outcomes = [ + { + id: 1, + abbreviation: 'LO1', + fullOutcomeDescription: 'Explain the solution.', + linkedOutcomeIds: [2, 3], + }, + { + id: 2, + abbreviation: 'O1', + shortDescription: 'Critical thinking', + fullOutcomeDescription: 'Think critically.', + linkedOutcomeIds: [], + }, + { + id: 3, + abbreviation: 'O2', + shortDescription: 'Clear communication', + fullOutcomeDescription: 'Communicate clearly.', + linkedOutcomeIds: [], + }, + ] as LearningOutcome[]; + fixture.componentRef.setInput('iloContextType', 'Unit'); + fixture.componentRef.setInput('unit', {ilos: outcomes}); + fixture.detectChanges(); + await fixture.whenStable(); + + const root: HTMLElement = fixture.nativeElement; + await expectAccessible(root); + const lists = root.querySelectorAll('[role="list"]'); + expect(lists).toHaveLength(1); + expect(lists[0].getAttribute('aria-label')).toBe('Linked outcomes for LO1'); + const chips = Array.from(lists[0].querySelectorAll('[role="listitem"]')); + expect(chips.map((chip) => chip.textContent.trim())).toEqual(['O1', 'O2']); + expect(root.querySelector('mat-chip-row, [role="gridcell"]')).toBeNull(); + for (const chip of chips) { + expect(chip.tabIndex).toBe(-1); + expect(chip.querySelector('button, input, [role="option"], [tabindex="0"]')).toBeNull(); + } + expect( + chips.map( + (chip) => document.getElementById(chip.getAttribute('aria-describedby'))?.textContent, + ), + ).toEqual(['Critical thinking', 'Clear communication']); + }); + + it('does not render an empty outcomes card or an empty linked-outcome list', async () => { + const fixture = TestBed.createComponent(TaskIlosCardComponent); + fixture.componentRef.setInput('iloContextType', 'Unit'); + fixture.componentRef.setInput('unit', {ilos: []}); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('mat-card, [role="list"]')).toBeNull(); + }); +}); diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html index 0594581649..822f5936ca 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html @@ -1,6 +1,7 @@ @if (triggers?.length > 0) { + Task status -

    {{ task?.statusLabel() }}

    + {{ task?.statusLabel() }} @for (trigger of triggers; track trigger) {
    -
    {{ trigger.label }}
    + {{ trigger.label }}
    } @@ -33,7 +34,7 @@
    {{ trigger.label }}
    style="margin-right: 10px" [status]="$safeNavigationMigration(task?.status)" > -
    {{ task?.statusLabel() }}
    + {{ task?.statusLabel() }} } @@ -73,7 +74,7 @@
    {{ task?.statusLabel() }}
    -
    +
    diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.spec.ts index d377730a00..bb9b8ab881 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.spec.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.spec.ts @@ -1,8 +1,16 @@ -import {beforeEach, describe, expect, it} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {OverlayContainer} from '@angular/cdk/overlay'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCardModule} from '@angular/material/card'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatMenuModule} from '@angular/material/menu'; +import {MatSelectModule} from '@angular/material/select'; import {ActivatedRoute} from '@angular/router'; import {EMPTY} from 'rxjs'; +import {Task} from 'src/app/api/models/task'; +import {TaskStatus} from 'src/app/api/models/task-status'; import {TaskService} from 'src/app/api/services/task.service'; import {UserService} from 'src/app/api/services/user.service'; import {ExtensionModalService} from 'src/app/common/modals/extension-modal/extension-modal.service'; @@ -24,6 +32,7 @@ describe('TaskStatusCardComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TaskStatusCardComponent], + imports: [MatButtonModule, MatCardModule, MatFormFieldModule, MatMenuModule, MatSelectModule], providers: [ {provide: ExtensionModalService, useValue: emptyProvider}, {provide: TaskService, useValue: taskServiceStub}, @@ -31,21 +40,71 @@ describe('TaskStatusCardComponent', () => { {provide: QrModalService, useValue: emptyProvider}, {provide: DoubtfireConstants, useValue: emptyProvider}, {provide: SubmissionTypeModalService, useValue: emptyProvider}, - {provide: UserService, useValue: emptyProvider}, + {provide: UserService, useValue: {currentUser: {systemRole: 'Student'}}}, {provide: FeedbackAppealModalService, useValue: emptyProvider}, ], schemas: [NO_ERRORS_SCHEMA], - }) - .overrideComponent(TaskStatusCardComponent, {set: {template: ''}}) - .compileComponents(); + }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TaskStatusCardComponent); component = fixture.componentInstance; + component.task = { + status: 'working_on_it', + statusLabel: () => 'Working On It', + statusHelp: () => ({reason: 'Keep working.', action: ''}), + blockedByPrerequisiteTasks: vi.fn().mockReturnValue(false), + canApplyForExtension: () => false, + inSubmittedState: () => false, + triggerTransition: vi.fn(), + } as unknown as Task; + component.triggers = [ + TaskStatus.statusData('working_on_it'), + TaskStatus.statusData('need_help'), + ]; + fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + const combobox = (): HTMLElement => fixture.nativeElement.querySelector('[role="combobox"]'); + + const accessibleLabel = (): string => + combobox() + .getAttribute('aria-labelledby') + .split(' ') + .map((id) => document.getElementById(id)?.textContent) + .join(' '); + + it('names the status combobox and keeps status text out of the heading list', async () => { + expect(accessibleLabel()).toContain('Task status'); + expect(fixture.nativeElement.querySelector('h2, h5')).toBeNull(); + + combobox().click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const overlay = TestBed.inject(OverlayContainer).getContainerElement(); + expect(overlay.querySelector('h2, h5')).toBeNull(); + const options = Array.from(overlay.querySelectorAll('[role="option"]')); + expect(options).toHaveLength(2); + expect(options[1].textContent).toContain('Need Help'); + options[1].click(); + fixture.detectChanges(); + + expect(component.task.triggerTransition).toHaveBeenCalledWith('need_help'); + }); + + it('keeps the label available when prerequisites disable the selector', () => { + vi.mocked(component.task.blockedByPrerequisiteTasks).mockReturnValue(true); + fixture.detectChanges(); + + expect(accessibleLabel()).toContain('Task status'); + expect(combobox().getAttribute('aria-disabled')).toBe('true'); + combobox().click(); + fixture.detectChanges(); + expect( + TestBed.inject(OverlayContainer).getContainerElement().querySelector('[role="listbox"]'), + ).toBeNull(); + expect(component.task.triggerTransition).not.toHaveBeenCalled(); }); }); diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.accessibility.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.accessibility.spec.ts new file mode 100644 index 0000000000..6635cc5dd7 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.accessibility.spec.ts @@ -0,0 +1,87 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatMenuModule} from '@angular/material/menu'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {MatTabsModule} from '@angular/material/tabs'; +import {ActivatedRoute} from '@angular/router'; +import {BehaviorSubject} from 'rxjs'; +import {Task} from 'src/app/api/models/task'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {DashboardViews, SelectedTaskService} from '../../selected-task.service'; +import {TaskDashboardComponent} from './task-dashboard.component'; + +describe('Task dashboard rendered tabs', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TaskDashboardComponent], + imports: [ + MatButtonModule, + MatIconModule, + MatMenuModule, + MatProgressSpinnerModule, + MatTabsModule, + ], + providers: [ + {provide: TaskService, useValue: {markedStatuses: [], statusSeq: new Map()}}, + {provide: UserService, useValue: {}}, + {provide: ActivatedRoute, useValue: {}}, + {provide: FileDownloaderService, useValue: {}}, + { + provide: SelectedTaskService, + useValue: {currentView$: new BehaviorSubject(DashboardViews.details)}, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + fixture = TestBed.createComponent(TaskDashboardComponent); + fixture.componentInstance.task = { + definition: {hasTaskSheet: true}, + hasPdf: false, + processingPdf: false, + project: {}, + unit: {staff: []}, + submissionUrl: () => '/synthetic-submission.pdf', + } as unknown as Task; + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('retains Material disabled semantics for an unavailable submission', () => { + const root: HTMLElement = fixture.nativeElement; + const tabs = Array.from(root.querySelectorAll('[role="tab"]')); + expect(tabs.map((tab) => tab.textContent.trim())).toEqual([ + 'Task Details', + 'Task Sheet', + 'Your Submission', + ]); + expect(tabs[0].getAttribute('aria-selected')).toBe('true'); + expect(tabs[1].getAttribute('aria-disabled')).toBe('false'); + expect(tabs[2].getAttribute('aria-disabled')).toBe('true'); + expect(tabs[2].classList.contains('mat-mdc-tab-disabled')).toBe(true); + expect(tabs[2].tabIndex).toBe(-1); + tabs[2].click(); + expect(fixture.componentInstance.currentView).toBe(DashboardViews.details); + }); + + it('keeps an available submission as an enabled, selectable inactive tab', async () => { + Object.assign(fixture.componentInstance.task, {hasPdf: true}); + fixture.detectChanges(); + await fixture.whenStable(); + const submission: HTMLElement = fixture.nativeElement.querySelectorAll('[role="tab"]')[2]; + expect(submission.getAttribute('aria-disabled')).toBe('false'); + expect(submission.classList.contains('mat-mdc-tab-disabled')).toBe(false); + expect(submission.getAttribute('aria-selected')).toBe('false'); + submission.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.componentInstance.currentView).toBe(DashboardViews.submission); + expect(submission.getAttribute('aria-selected')).toBe('true'); + }); +}); diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.scss index 2b2f7b6954..179dfac9a9 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.scss +++ b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.scss @@ -1,17 +1,34 @@ +@use '@angular/material' as mat; + .empty-state-icon { height: 120px; width: 120px; font-size: 120px; - color: #c5c5c5; + color: var(--ot-color-text-muted); } .no-selection-text { font-size: 2.5rem; - color: #c5c5c5; + color: var(--ot-color-text-muted); } :host ::ng-deep .task-dashboard-tabs { .mat-mdc-tab-header { + // Filled-control primary colours are not text colours on every theme surface. + @include mat.tabs-overrides( + ( + active-label-text-color: var(--ot-color-link), + active-focus-label-text-color: var(--ot-color-link), + active-hover-label-text-color: var(--ot-color-link), + active-indicator-color: var(--ot-color-link), + active-focus-indicator-color: var(--ot-color-link), + active-hover-indicator-color: var(--ot-color-link), + inactive-label-text-color: var(--ot-color-text-muted), + inactive-focus-label-text-color: var(--ot-color-text-muted), + inactive-hover-label-text-color: var(--ot-color-text-muted), + ) + ); + justify-content: center; max-width: 100%; overflow-x: auto; diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html deleted file mode 100644 index b7adf8c446..0000000000 --- a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html +++ /dev/null @@ -1,130 +0,0 @@ -
    -
    -
    - {{task.definition.name}} - {{task.definition.name}} - - - -
    -
    - -
    - - - - - - - - - - - - - - - - -
    - - -
    - - Warning: This task has {{task.definition.taskPrerequisitesCache.currentValues.length}} - prerequisite{{ task.definition.taskPrerequisitesCache.currentValues.length > 1 ? 's' : '' }} - that you still need to complete. You won’t be able to submit this task until all prerequisites - are met. -
    - -
    - -
    - -
    - - -
    -
    - -
    -
    - -
    - -
    - - PDF Still Processing - No Submission Uploaded - No Task Selected -
    - - - -
    diff --git a/src/app/projects/states/jplag/jplag-report-viewer.component.html b/src/app/projects/states/jplag/jplag-report-viewer.component.html index 1b5e99f233..df7342d9c1 100644 --- a/src/app/projects/states/jplag/jplag-report-viewer.component.html +++ b/src/app/projects/states/jplag/jplag-report-viewer.component.html @@ -4,6 +4,7 @@ frameborder="0" src="/JPlag/" style="overflow: hidden" + title="JPlag similarity report" [hidden]="hidden" [scrolling]="false" > diff --git a/src/app/projects/states/plan/task-planner/task-planner.component.scss b/src/app/projects/states/plan/task-planner/task-planner.component.scss index df94c4a868..a3848ec38f 100644 --- a/src/app/projects/states/plan/task-planner/task-planner.component.scss +++ b/src/app/projects/states/plan/task-planner/task-planner.component.scss @@ -36,6 +36,14 @@ background-color: var(--bar-bg); } +// The bar is reachable by tab, so its focus has to be visible. currentColor is already +// chosen per bar to contrast with that bar's background, so it works on every colour the +// planner uses. +.gantt-bar:focus-visible { + outline: 2px solid currentColor; + outline-offset: -3px; +} + .flash { transition: background-color 500ms ease-in-out; } diff --git a/src/app/projects/states/staff-notes/staff-notes.component.html b/src/app/projects/states/staff-notes/staff-notes.component.html index 13b472e7cb..ad32dfe953 100644 --- a/src/app/projects/states/staff-notes/staff-notes.component.html +++ b/src/app/projects/states/staff-notes/staff-notes.component.html @@ -1,7 +1,7 @@
    @if (!loadingStaffNotes && project?.staffNoteCount === 0) {
    - No staff notes for {{ project.student.preferredName }} {{ project.student.lastName }} + No staff notes for {{ project.student.displayName }}
    }
    @@ -11,8 +11,10 @@
    -
    @@ -28,26 +30,42 @@ Replying to: Deleted note }
    -
    +
    } - -
    + +
    @if (note.authorIsMe) { - edit + } - reply - delete + +
    - {{ note.user?.firstName }} {{ note.user?.lastName }}
    reply
    - Replying to {{ replyingToNote.user.firstName }} {{ replyingToNote.user.lastName }} ({{ - replyingToNote.user.nickname - }}) + Replying to {{ replyingToNote.user.displayName }} ({{ replyingToNote.user.nickname }}) {{ replyingToNote.note }}
    - close +
    } diff --git a/src/app/projects/states/staff-notes/staff-notes.component.scss b/src/app/projects/states/staff-notes/staff-notes.component.scss index a6d41ed2d2..8fd9c46e72 100644 --- a/src/app/projects/states/staff-notes/staff-notes.component.scss +++ b/src/app/projects/states/staff-notes/staff-notes.component.scss @@ -13,6 +13,41 @@ color: black; } +// Edit, reply and delete are real buttons, stripped back to look the way the bare icons +// they replaced did. +.note-action { + padding: 0; + border: 0; + background: none; + line-height: 0; + cursor: pointer; +} + +.note-action:focus-visible { + border-radius: 2px; + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.note-action:hover .mat-icon, +.note-action:focus-visible .mat-icon { + color: black; +} + +// The row used to be shown by a mouseover handler writing hoveredNoteId, which no keyboard +// user could ever set. The reveal is CSS now, so it answers to focus inside the note as +// well as to the pointer. It has to be opacity rather than display or visibility, both of +// which would drop the buttons out of the tab order and leave :focus-within with no way to +// ever become true. +.note-actions { + opacity: 0; +} + +.note-card:hover .note-actions, +.note-card:focus-within .note-actions { + opacity: 1; +} + @keyframes blueGlowFade { 0% { background-color: rgba(66, 133, 244, 0.6); diff --git a/src/app/projects/states/staff-notes/staff-notes.component.ts b/src/app/projects/states/staff-notes/staff-notes.component.ts index 6ca071e75e..5cbf267a29 100644 --- a/src/app/projects/states/staff-notes/staff-notes.component.ts +++ b/src/app/projects/states/staff-notes/staff-notes.component.ts @@ -34,8 +34,6 @@ export class StaffNotesComponent implements OnInit { replyingToNote?: StaffNote; - hoveredNoteId: number | null = null; - constructor( private userService: UserService, private staffNoteService: StaffNoteService, diff --git a/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts b/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts index 8845bfecd1..f9c2723554 100644 --- a/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts +++ b/src/app/projects/states/tutor-discussion/tutor-discussion.component.ts @@ -66,7 +66,6 @@ export class TutorDiscussionComponent implements AfterViewInit, OnDestroy { public project: Project | null; public selectedTask: Task | null; - public allowHover = true; public isNarrow = false; public scanningQr: boolean = false; diff --git a/src/app/projects/states/tutor-notes/tutor-notes.component.html b/src/app/projects/states/tutor-notes/tutor-notes.component.html index ec2c4581d4..bf61e36c77 100644 --- a/src/app/projects/states/tutor-notes/tutor-notes.component.html +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.html @@ -1,7 +1,7 @@
    @@ -11,8 +11,10 @@
    -
    @@ -28,22 +30,38 @@ Replying to: Deleted note }
    -
    +
    } - +
    -
    +
    @if (note.authorIsMe) { - edit + } - reply - delete + +
    @if (!note.readByUnitRole) { @if (note.noteIsForMe) { @@ -59,7 +77,7 @@
    - {{ note.user?.firstName }} {{ note.user?.lastName }}
    {{ note.project?.student.name }}{{ note.project?.student.displayName }} }
    @@ -149,14 +167,19 @@ reply
    - Replying to {{ replyingToNote.user.firstName }} {{ replyingToNote.user.lastName }} ({{ - replyingToNote.user.nickname - }}) + Replying to {{ replyingToNote.user.displayName }} ({{ replyingToNote.user.nickname }}) {{ replyingToNote.note }}
    - close +
    } diff --git a/src/app/projects/states/tutor-notes/tutor-notes.component.scss b/src/app/projects/states/tutor-notes/tutor-notes.component.scss index a6d41ed2d2..8fd9c46e72 100644 --- a/src/app/projects/states/tutor-notes/tutor-notes.component.scss +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.scss @@ -13,6 +13,41 @@ color: black; } +// Edit, reply and delete are real buttons, stripped back to look the way the bare icons +// they replaced did. +.note-action { + padding: 0; + border: 0; + background: none; + line-height: 0; + cursor: pointer; +} + +.note-action:focus-visible { + border-radius: 2px; + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.note-action:hover .mat-icon, +.note-action:focus-visible .mat-icon { + color: black; +} + +// The row used to be shown by a mouseover handler writing hoveredNoteId, which no keyboard +// user could ever set. The reveal is CSS now, so it answers to focus inside the note as +// well as to the pointer. It has to be opacity rather than display or visibility, both of +// which would drop the buttons out of the tab order and leave :focus-within with no way to +// ever become true. +.note-actions { + opacity: 0; +} + +.note-card:hover .note-actions, +.note-card:focus-within .note-actions { + opacity: 1; +} + @keyframes blueGlowFade { 0% { background-color: rgba(66, 133, 244, 0.6); diff --git a/src/app/projects/states/tutor-notes/tutor-notes.component.spec.ts b/src/app/projects/states/tutor-notes/tutor-notes.component.spec.ts new file mode 100644 index 0000000000..d72f63f1ed --- /dev/null +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.spec.ts @@ -0,0 +1,104 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {EMPTY} from 'rxjs'; +import {UserService} from 'src/app/api/models/doubtfire-model'; +import {TutorNote} from 'src/app/api/models/tutor-note'; +import {TutorNoteService} from 'src/app/api/services/tutor-note.service'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {HumanizedDatePipe} from 'src/app/common/pipes/humanized-date.pipe'; +import {LocalizedDatePipe} from 'src/app/common/pipes/localized-date.pipe'; +import {MarkedPipe} from 'src/app/common/pipes/marked.pipe'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {TutorNotesComponent} from './tutor-notes.component'; + +const emptyProvider = {}; +const tutorNoteServiceStub = { + loadTutorNotes: () => EMPTY, + updateTutorNoteReplies: () => undefined, +}; + +describe('TutorNotesComponent', () => { + let component: TutorNotesComponent; + let fixture: ComponentFixture; + let note: TutorNote; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TutorNotesComponent, HumanizedDatePipe, LocalizedDatePipe, MarkedPipe], + providers: [ + {provide: UserService, useValue: emptyProvider}, + {provide: TutorNoteService, useValue: tutorNoteServiceStub}, + {provide: AlertService, useValue: emptyProvider}, + {provide: ConfirmationModalService, useValue: emptyProvider}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TutorNotesComponent); + component = fixture.componentInstance; + note = { + id: 3, + replyToId: null, + note: 'a tutor note', + user: {}, + authorIsMe: true, + noteIsForMe: true, + readByUnitRole: false, + } as TutorNote; + component.unitRole = {tutorNotesCache: {currentValues: [note]}} as never; + + fixture.detectChanges(); + component.loadingTutorNotes = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + function card(): HTMLElement { + return fixture.nativeElement.querySelector('.note-card') as HTMLElement; + } + + function editButton(): HTMLButtonElement { + return card().querySelector('.note-actions button') as HTMLButtonElement; + } + + it('renders the note actions instead of gating them behind a pointer flag', () => { + const actions = card().querySelector('.note-actions') as HTMLElement; + + expect(actions).toBeTruthy(); + expect(actions.hasAttribute('hidden')).toBe(false); + }); + + it('builds the actions out of real buttons rather than bare icons', () => { + const buttons = card().querySelectorAll('.note-actions button'); + + expect(buttons.length).toBe(3); + expect(editButton().getAttribute('aria-label')).toBe('Edit this note'); + }); + + it('does not meet the reveal condition while nothing in the note has focus', () => { + expect(card().matches(':focus-within')).toBe(false); + }); + + it('meets the reveal condition once the keyboard reaches the actions', () => { + editButton().focus(); + + expect(document.activeElement).toBe(editButton()); + expect(card().matches(':focus-within')).toBe(true); + }); + + it('opens the editor through the button the pointer uses, not through injected state', () => { + const button = editButton(); + button.focus(); + button.click(); + fixture.detectChanges(); + + expect(component.editingNote).toBe(note); + expect(card().querySelector('textarea')).toBeTruthy(); + }); +}); diff --git a/src/app/projects/states/tutor-notes/tutor-notes.component.ts b/src/app/projects/states/tutor-notes/tutor-notes.component.ts index 94f258a5ae..4ee499642b 100644 --- a/src/app/projects/states/tutor-notes/tutor-notes.component.ts +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.ts @@ -35,8 +35,6 @@ export class TutorNotesComponent implements OnInit { replyingToNote?: TutorNote; - hoveredNoteId: number | null = null; - constructor( private userService: UserService, private tutorNoteService: TutorNoteService, diff --git a/src/app/sessions/states/sign-in/sign-in.component.html b/src/app/sessions/states/sign-in/sign-in.component.html index 2c57ca1db0..be436d6337 100644 --- a/src/app/sessions/states/sign-in/sign-in.component.html +++ b/src/app/sessions/states/sign-in/sign-in.component.html @@ -27,13 +27,20 @@

    @if (showCredentials) { Username - + } @if (showCredentials) { Password

    +
    } diff --git a/src/app/tasks/modals/grade-task-modal/grade-task-modal.accessibility.spec.ts b/src/app/tasks/modals/grade-task-modal/grade-task-modal.accessibility.spec.ts new file mode 100644 index 0000000000..b4e8e5e5b5 --- /dev/null +++ b/src/app/tasks/modals/grade-task-modal/grade-task-modal.accessibility.spec.ts @@ -0,0 +1,83 @@ +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; +import {MatCardModule} from '@angular/material/card'; +import {MatDialog, MatDialogModule} from '@angular/material/dialog'; +import {MatSliderModule} from '@angular/material/slider'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {expectAccessible} from 'src/app/common/testing/accessibility'; +import {GradeTaskModalComponent} from './grade-task-modal.component'; + +describe('GradeTaskModalComponent rendered accessibility', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GradeTaskModalComponent], + imports: [ + FormsModule, + MatButtonModule, + MatButtonToggleModule, + MatCardModule, + MatDialogModule, + MatSliderModule, + MatTooltipModule, + ], + providers: [ + { + provide: GradeService, + useValue: { + allGradeValuesFor: () => [1, 2], + gradeLabel: (grade: number) => (grade === 1 ? 'Pass' : 'Credit'), + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + afterEach(() => TestBed.inject(MatDialog).closeAll()); + + it('names the real dialog, grade group and quality slider without invalid ID references', async () => { + const ref = TestBed.inject(MatDialog).open(GradeTaskModalComponent, { + data: { + task: { + grade: 1, + qualityPts: 2, + unit: {}, + definition: {isGraded: true, maxQualityPts: 5, abbreviation: 'DEMO'}, + }, + }, + }); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + const dialog = document.querySelector('mat-dialog-container')!; + await expectAccessible(dialog as HTMLElement); + const titleId = dialog.getAttribute('aria-labelledby')!; + expect(document.getElementById(titleId)?.textContent).toContain('Assess Task Quality'); + expect(dialog.querySelector('[role="radiogroup"]')?.getAttribute('aria-label')).toBe( + 'Task grade', + ); + const options = dialog.querySelectorAll('mat-button-toggle button'); + expect([...options].map((option) => option.getAttribute('aria-label'))).toEqual([ + 'Mark task as Pass', + 'Mark task as Credit', + ]); + for (const described of dialog.querySelectorAll('[aria-describedby]')) { + for (const id of described.getAttribute('aria-describedby')!.split(/\s+/)) { + expect(document.getElementById(id)).not.toBeNull(); + } + } + const slider = dialog.querySelector('input[matSliderThumb]')!; + expect(slider.getAttribute('aria-label')).toBe('Task quality rating'); + expect(slider.getAttribute('aria-valuetext')).toBe('2 out of 5'); + ref.componentInstance.updateRating(0); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + expect(slider.getAttribute('aria-valuetext')).toBe('0 out of 5'); + expect(ref.componentInstance.isValid()).toBeTruthy(); + }); +}); diff --git a/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.html b/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.html index 2b8072a9dd..758a76f283 100644 --- a/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.html +++ b/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.html @@ -1,6 +1,6 @@ - Assess Task Quality +

    Assess Task Quality

    @@ -17,15 +17,13 @@
    @for (idx of gradeValues; track idx) {

    - - + +

    Rating: {{ rating }} / {{ totalRating }}

    diff --git a/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.ts b/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.ts index b6a8adb053..0c5dc3709b 100644 --- a/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.ts +++ b/src/app/tasks/modals/grade-task-modal/grade-task-modal.component.ts @@ -22,6 +22,8 @@ export class GradeTaskModalComponent implements OnInit { // Grade Select selectedGrade: number; + readonly formatQualityRating = (value: number): string => `${value} out of ${this.totalRating}`; + constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public dialogData: {task: Task}, diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.accessibility.spec.ts b/src/app/tasks/task-comment-composer/task-comment-composer.accessibility.spec.ts new file mode 100644 index 0000000000..f76037d3d0 --- /dev/null +++ b/src/app/tasks/task-comment-composer/task-comment-composer.accessibility.spec.ts @@ -0,0 +1,109 @@ +import {EmojiSearch} from '@ctrl/ngx-emoji-mart'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {CommonModule} from '@angular/common'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatDialog} from '@angular/material/dialog'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {of} from 'rxjs'; +import {TaskCommentService, UserService} from 'src/app/api/models/doubtfire-model'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {EmojiService} from 'src/app/common/services/emoji.service'; +import {expectAccessible} from 'src/app/common/testing/accessibility'; +import {TaskCommentsViewerComponent} from '../task-comments-viewer/task-comments-viewer.component'; +import {TaskCommentComposerComponent} from './task-comment-composer.component'; + +describe('Shared task comment controls', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + for (const name of ['localStorage', 'sessionStorage']) { + vi.stubGlobal(name, {getItem: () => null, setItem: vi.fn(), removeItem: vi.fn()}); + } + await TestBed.configureTestingModule({ + imports: [CommonModule, MatButtonModule], + declarations: [TaskCommentComposerComponent], + providers: [ + provideNoopAnimations(), + {provide: MatDialog, useValue: {}}, + {provide: EmojiSearch, useValue: {}}, + {provide: EmojiService, useValue: {}}, + {provide: TaskCommentsViewerComponent, useValue: {scrollDown: vi.fn()}}, + {provide: AlertService, useValue: {}}, + { + provide: TaskCommentService, + useValue: { + attachmentPolicy: () => + of({ + version: 1, + max_bytes_exclusive: 10_000_000, + max_selection_count: 5, + categories: [{id: 'pdf', name: 'PDF', extensions: ['pdf'], preview: 'pdf'}], + }), + }, + }, + {provide: UserService, useValue: {currentUser: {id: 1}}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + fixture = TestBed.createComponent(TaskCommentComposerComponent); + fixture.componentRef.setInput('task', {id: 123, unit: {currentUserIsStaff: false}}); + fixture.componentRef.setInput('sharedData', {originalComment: null, editingComment: null}); + fixture.detectChanges(); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('passes automated accessibility checks for student and staff feedback controls', async () => { + await fixture.whenStable(); + await expectAccessible(fixture.nativeElement); + fixture.componentRef.setInput('task', {id: 456, unit: {currentUserIsStaff: true}}); + fixture.detectChanges(); + await fixture.whenStable(); + await expectAccessible(fixture.nativeElement); + }); + + it('exposes a named multiline textbox and removes it from focus while recording', () => { + const editor = fixture.nativeElement.querySelector('[role="textbox"]') as HTMLElement; + expect(editor.getAttribute('aria-label')).toBe('Task comment'); + expect(editor.getAttribute('aria-multiline')).toBe('true'); + expect(editor.tabIndex).toBe(0); + editor.focus(); + expect(document.activeElement).toBe(editor); + fixture.componentInstance.recording = true; + fixture.detectChanges(); + expect(editor.hidden).toBe(true); + expect(editor.tabIndex).toBe(-1); + expect(editor.getAttribute('aria-disabled')).toBe('true'); + }); + + it('uses a focusable native button to open and close emoji choices once per activation', () => { + const button = fixture.nativeElement.querySelector( + 'button[aria-label="Choose an emoji"]', + ) as HTMLButtonElement; + expect(button.type).toBe('button'); + expect(button.tabIndex).toBe(0); + button.click(); + fixture.detectChanges(); + expect(button.getAttribute('aria-expanded')).toBe('true'); + button.click(); + fixture.detectChanges(); + expect(button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps the staff feedback template action hidden from students', () => { + const button = fixture.nativeElement.querySelector( + 'button[aria-label="Choose feedback templates"]', + ) as HTMLButtonElement; + expect(button.closest('[hidden]')).toBeTruthy(); + fixture.componentRef.setInput('task', {id: 456, unit: {currentUserIsStaff: true}}); + fixture.detectChanges(); + expect(button.closest('[hidden]')).toBeNull(); + const open = vi.spyOn(fixture.componentInstance, 'showFeedbackPicker'); + button.click(); + fixture.detectChanges(); + expect(open).toHaveBeenCalledOnce(); + expect(button.getAttribute('aria-expanded')).toBe('true'); + }); +}); diff --git a/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html b/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html index f3b9b5c748..a0703f57bb 100644 --- a/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html +++ b/src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.html @@ -3,33 +3,36 @@ aria-label="React to this comment"> tag_faces --> - - reply - reply + + +
    diff --git a/src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.accessibility.spec.ts b/src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.accessibility.spec.ts new file mode 100644 index 0000000000..66c612b91e --- /dev/null +++ b/src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.accessibility.spec.ts @@ -0,0 +1,77 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatIconModule} from '@angular/material/icon'; +import {TaskComment} from 'src/app/api/models/doubtfire-model'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {CommentsModalService} from 'src/app/common/modals/comments-modal/comments-modal.service'; +import {SafePipe} from 'src/app/common/pipes/safe.pipe'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {PdfImageCommentComponent} from './pdf-image-comment.component'; + +describe('PDF and image attachment keyboard controls', () => { + let fixture: ComponentFixture; + let downloader: {downloadBlob: ReturnType; releaseBlob: ReturnType}; + let modal: {show: ReturnType}; + let alerts: {error: ReturnType}; + + beforeEach(async () => { + downloader = { + downloadBlob: vi.fn((_url, success) => success('blob:attachment', undefined)), + releaseBlob: vi.fn(), + }; + modal = {show: vi.fn()}; + alerts = {error: vi.fn()}; + await TestBed.configureTestingModule({ + declarations: [PdfImageCommentComponent, SafePipe], + imports: [MatIconModule], + providers: [ + {provide: FileDownloaderService, useValue: downloader}, + {provide: CommentsModalService, useValue: modal}, + {provide: AlertService, useValue: alerts}, + ], + }).compileComponents(); + fixture = TestBed.createComponent(PdfImageCommentComponent); + }); + + it.each(['pdf', 'image'])('opens the %s preview from a named native button', (commentType) => { + const comment = {commentType, attachmentUrl: '/attachment'} as TaskComment; + fixture.componentInstance.comment = comment; + fixture.detectChanges(); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button).not.toBeNull(); + expect(button.type).toBe('button'); + expect(button.tabIndex).toBe(0); + expect(button.getAttribute('aria-label')).toBe( + commentType === 'pdf' ? 'View PDF attachment' : 'View image attachment', + ); + button.focus(); + expect(document.activeElement).toBe(button); + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + button.dispatchEvent(enter); + expect(enter.defaultPrevented).toBe(false); + // jsdom does not synthesize the native button activation from Enter. + button.click(); + + expect(modal.show).toHaveBeenCalledExactlyOnceWith('blob:attachment', comment); + expect(downloader.downloadBlob).toHaveBeenCalledTimes(1); + expect(alerts.error).not.toHaveBeenCalled(); + if (commentType === 'image') { + expect(button.querySelector('img').getAttribute('alt')).toBe('Image attachment preview'); + } + }); + + it('reports a failed attachment download without opening an empty preview', () => { + downloader.downloadBlob.mockImplementation((_url, _success, failure) => failure('offline')); + fixture.componentInstance.comment = { + commentType: 'pdf', + attachmentUrl: '/attachment', + } as TaskComment; + fixture.detectChanges(); + + fixture.nativeElement.querySelector('button').click(); + + expect(modal.show).not.toHaveBeenCalled(); + expect(alerts.error).toHaveBeenCalledWith('Unable to download image comment. offline', 6000); + }); +}); diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index 4b029a8639..9ef9cd662c 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -1,23 +1,24 @@
    -
    +
    - @if (!task || task.comments.length === 0) { -
    - forum + @if (!loading && (!task || task.comments.length === 0)) { +
    + +

    No comments on this task yet.

    } @@ -25,12 +26,7 @@
    @for (comment of task.comments; track comment) { -
    +
    @if (comment.shouldShowTimestamp) {

    - {{ comment.author.preferredName }} {{ comment.author.lastName }} + {{ comment.author.displayName }} {{ comment.createdAt | humanizedDate }}

    }
    @if (comment.shouldShowAvatar && shouldShowAuthorIcon(comment.commentType)) { @switch (comment.commentType) { @case ('status') { @@ -92,12 +87,14 @@ > - group + group {{ comment.text }} @@ -114,12 +111,14 @@ > - comment + comment {{ comment.text }} @@ -136,12 +135,14 @@ > - how_to_reg + how_to_reg {{ comment.text }} @@ -161,7 +162,7 @@ } @case ('assessment') { -
    +
    @if ( overseerEnabled && asAssessmentComment(comment); as assessmentComment @@ -185,9 +186,12 @@
    @if (!!comment.replyToId) { -

    reply @@ -197,9 +201,33 @@ : 'comment removed' }}

    -
    + } + @if ( + comment.commentType === 'document' || comment.commentType === 'spreadsheet' + ) { +
    + {{ comment.attachmentFileName || 'Attachment' }} + {{ + comment.commentType === 'spreadsheet' ? 'Spreadsheet' : 'Document' + }} + · {{ comment.attachmentByteSize }} bytes · Download only + +

    {{ comment.text }}

    +
    + } @switch (comment.commentType) { @case ('extension') {
    @@ -279,7 +307,6 @@
    @@ -308,6 +335,7 @@