Merge pull request 'Improving accessibility score up to 100, hot update to v16.0.1' (#180) from accessibility-maxing into main
Some checks failed
Release / Build-production-and-ng-test (push) Failing after 1m13s
Release / Build-and-test-development (push) Has been skipped
Release / release (push) Has been skipped

Reviewed-on: #180
This commit is contained in:
2025-07-23 13:21:40 +00:00
16 changed files with 1308 additions and 473 deletions

View File

@@ -10,7 +10,7 @@ const check = (cwd) => {
onlyAllow: onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;', 'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages: excludePackages:
'@cds/city@1.1.0;@handsontable/angular@15.3.0;handsontable@15.3.0;hyperformula@2.7.1;hyperformula@3.0.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1' '@cds/city@1.1.0;@handsontable/angular@16.0.1;handsontable@16.0.1;hyperformula@2.7.1;hyperformula@3.0.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1'
}, },
(error, json) => { (error, json) => {
if (error) { if (error) {

984
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,8 +48,8 @@
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz", "@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2", "@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz", "@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular": "^15.3.0", "@handsontable/angular": "^16.0.1",
"@sasjs/adapter": "^4.12.1", "@sasjs/adapter": "^4.12.2",
"@sasjs/utils": "^3.4.0", "@sasjs/utils": "^3.4.0",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz", "@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7", "@types/d3-graphviz": "^2.6.7",
@@ -60,7 +60,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"d3-graphviz": "^5.0.2", "d3-graphviz": "^5.0.2",
"fs-extra": "^7.0.1", "fs-extra": "^7.0.1",
"handsontable": "^15.3.0", "handsontable": "^16.0.1",
"https-browserify": "1.0.0", "https-browserify": "1.0.0",
"hyperformula": "^2.5.0", "hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0", "iconv-lite": "^0.5.0",

View File

@@ -3,6 +3,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
OnDestroy,
OnInit, OnInit,
QueryList, QueryList,
ViewChild, ViewChild,
@@ -71,7 +72,7 @@ import { ParseResult } from '../models/ParseResult.interface'
}, },
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class EditorComponent implements OnInit, AfterViewInit { export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChildren('uploadStater') @ViewChildren('uploadStater')
uploadStaterCompList: QueryList<UploadStaterComponent> = new QueryList() uploadStaterCompList: QueryList<UploadStaterComponent> = new QueryList()
@ViewChildren('queryFilter') @ViewChildren('queryFilter')
@@ -124,7 +125,7 @@ export class EditorComponent implements OnInit, AfterViewInit {
colHeaders: [], colHeaders: [],
hidden: true, hidden: true,
columns: [], columns: [],
height: '100%', height: 'calc(100vh - 160px)',
minSpareRows: 1, minSpareRows: 1,
licenseKey: undefined, licenseKey: undefined,
readOnly: true, readOnly: true,
@@ -350,6 +351,9 @@ export class EditorComponent implements OnInit, AfterViewInit {
public licenceState = this.licenceService.licenceState public licenceState = this.licenceService.licenceState
private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined
constructor( constructor(
private licenceService: LicenceService, private licenceService: LicenceService,
private eventService: EventService, private eventService: EventService,
@@ -896,6 +900,11 @@ export class EditorComponent implements OnInit, AfterViewInit {
} }
this.reSetCellValidationValues() this.reSetCellValidationValues()
// Fix ARIA accessibility issues after table edit
setTimeout(() => {
this.fixAriaAccessibility()
}, 100)
}, 0) }, 0)
} }
@@ -2262,7 +2271,173 @@ export class EditorComponent implements OnInit, AfterViewInit {
} }
} }
ngAfterViewInit() {} ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 1000)
}
ngOnDestroy() {
// Clean up the MutationObserver
if (this.ariaObserver) {
this.ariaObserver.disconnect()
this.ariaObserver = undefined
}
// Clean up the interval
if (this.ariaCheckInterval) {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
}
/**
* Fixes ARIA accessibility issues in the Handsontable component
* This addresses the accessibility report issues with treegrid and presentation roles
*/
private fixAriaAccessibility() {
// Use a more aggressive approach to find and fix all ARIA issues
const fixAriaIssues = () => {
// Specifically target Handsontable wrapper elements that are causing issues
const hotWrappers = document.querySelectorAll(
'.ht-wrapper, .wtHolder, [id^="ht_"]'
)
hotWrappers.forEach((wrapper) => {
// Remove problematic ARIA attributes from Handsontable wrappers
wrapper.removeAttribute('role')
wrapper.removeAttribute('aria-rowcount')
wrapper.removeAttribute('aria-colcount')
wrapper.removeAttribute('aria-multiselectable')
})
// Find all elements with problematic ARIA roles in the entire document
const allTreegridElements = document.querySelectorAll('[role="treegrid"]')
const allPresentationElements = document.querySelectorAll(
'[role="presentation"]'
)
// Fix treegrid role issues - remove them completely as they're causing problems
allTreegridElements.forEach((element) => {
element.removeAttribute('role')
element.removeAttribute('aria-rowcount')
element.removeAttribute('aria-colcount')
element.removeAttribute('aria-multiselectable')
})
// Fix presentation role issues - remove them if they contain interactive elements
allPresentationElements.forEach((element) => {
const hasInteractiveChildren =
element.querySelectorAll(
'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
).length > 0
if (hasInteractiveChildren) {
element.removeAttribute('role')
}
})
// Also fix any elements with aria-rowcount="-1" which is problematic
const negativeRowCountElements = document.querySelectorAll(
'[aria-rowcount="-1"]'
)
negativeRowCountElements.forEach((element) => {
element.removeAttribute('aria-rowcount')
})
// Ensure proper table structure
const tableElements = document.querySelectorAll('table')
tableElements.forEach((table) => {
if (!table.getAttribute('role')) {
table.setAttribute('role', 'table')
}
// Ensure table headers have proper scope
const headerCells = table.querySelectorAll('th')
headerCells.forEach((th) => {
if (!th.getAttribute('scope')) {
th.setAttribute('scope', 'col')
}
})
})
// Add proper ARIA labels to interactive elements
const interactiveElements = document.querySelectorAll(
'button, input, select, textarea, [contenteditable]'
)
interactiveElements.forEach((element) => {
if (
!element.getAttribute('aria-label') &&
!element.getAttribute('aria-labelledby')
) {
const textContent = element.textContent?.trim()
if (textContent) {
element.setAttribute('aria-label', textContent)
}
}
})
}
// Run the fix immediately
fixAriaIssues()
// Run it again after a short delay to catch any dynamically created elements
setTimeout(fixAriaIssues, 100)
setTimeout(fixAriaIssues, 500)
setTimeout(fixAriaIssues, 1000)
setTimeout(fixAriaIssues, 2000)
// Set up a periodic check to ensure accessibility fixes are maintained
if (!this.ariaCheckInterval) {
this.ariaCheckInterval = setInterval(fixAriaIssues, 3000)
}
// Set up a MutationObserver to continuously monitor for new problematic elements
if (!this.ariaObserver) {
this.ariaObserver = new MutationObserver((mutations) => {
let shouldFix = false
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
(mutation.attributeName === 'role' ||
mutation.attributeName === 'aria-rowcount')
) {
shouldFix = true
}
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
if (
element.hasAttribute('role') ||
element.hasAttribute('aria-rowcount')
) {
shouldFix = true
}
}
})
}
})
if (shouldFix) {
setTimeout(fixAriaIssues, 50)
}
})
// Start observing the entire document for changes
this.ariaObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'role',
'aria-rowcount',
'aria-colcount',
'aria-multiselectable'
]
})
}
}
initSetup(response: EditorsGetDataServiceResponse) { initSetup(response: EditorsGetDataServiceResponse) {
this.hotInstance = this.hotRegisterer.getInstance('hotInstance') this.hotInstance = this.hotRegisterer.getInstance('hotInstance')
@@ -2597,6 +2772,17 @@ export class EditorComponent implements OnInit, AfterViewInit {
hot.addHook('afterRender', (isForced: boolean) => { hot.addHook('afterRender', (isForced: boolean) => {
this.eventService.dispatchEvent('resize') this.eventService.dispatchEvent('resize')
// Fix ARIA accessibility issues after each render
this.fixAriaAccessibility()
})
// Add a more frequent accessibility fix hook
hot.addHook('afterChange', () => {
// Fix ARIA accessibility issues after any data change
setTimeout(() => {
this.fixAriaAccessibility()
}, 50)
}) })
hot.addHook('afterCreateRow', (source: any, change: any) => { hot.addHook('afterCreateRow', (source: any, change: any) => {
@@ -2660,5 +2846,10 @@ export class EditorComponent implements OnInit, AfterViewInit {
} }
hot.render() hot.render()
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 500)
} }
} }

View File

@@ -99,6 +99,11 @@ export class MetadataComponent implements OnInit {
} }
this.pageSize = 5 this.pageSize = 5
// Initialize filters for accessibility
this.typeFilter = new TypeFilter()
this.nameFilter = new NameFilter()
this.valueFilter = new ValueFilter()
if ( if (
globals.metadata.metaDataList && globals.metadata.metaDataList &&
globals.metadata.metaRepositories && globals.metadata.metaRepositories &&

View File

@@ -33,12 +33,34 @@
<div class="clr-col-md-12" ng-if="loaded"> <div class="clr-col-md-12" ng-if="loaded">
<div *ngIf="approveList && remained !== 0"> <div *ngIf="approveList && remained !== 0">
<clr-datagrid class="datagrid-compact datagrid-custom-footer"> <clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column [clrDgField]="'submitter'">SUBMITTER</clr-dg-column> <clr-dg-column [clrDgField]="'submitter'">
<clr-dg-column [clrDgField]="'baseTable'">BASE TABLE</clr-dg-column> SUBMITTER
<clr-dg-column [clrDgField]="'submitted'">SUBMITTED</clr-dg-column> <clr-dg-string-filter
<clr-dg-column [clrDgField]="'submitReason'" [clrDgStringFilter]="submitterFilter"
>SUBMIT REASON</clr-dg-column aria-label="Filter submitter"
> ></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'baseTable'">
BASE TABLE
<clr-dg-string-filter
[clrDgStringFilter]="baseTableFilter"
aria-label="Filter base table"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>ACTION</clr-dg-column> <clr-dg-column>ACTION</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column> <clr-dg-column>DOWNLOAD</clr-dg-column>
@@ -51,15 +73,19 @@
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell> <clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
<clr-dg-cell> <clr-dg-cell>
<div <div
class="clr-row" class="clr-row d-flex justify-content-around"
role="tooltip" role="toolbar"
class="d-flex justify-content-around" aria-label="Table actions"
> >
<a <a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green" class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
(click)="getClicked(i)" (click)="getClicked(i)"
> >
<clr-icon shape="check" size="24"></clr-icon> <clr-icon
shape="check"
size="24"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content">Go to review page screen</span> <span class="tooltip-content">Go to review page screen</span>
</a> </a>
<a <a
@@ -70,10 +96,12 @@
*ngIf="!approveItem.rejectLoading" *ngIf="!approveItem.rejectLoading"
shape="ban" shape="ban"
size="22" size="22"
aria-hidden="true"
></clr-icon> ></clr-icon>
<clr-spinner <clr-spinner
*ngIf="approveItem.rejectLoading" *ngIf="approveItem.rejectLoading"
[clrSmall]="true" [clrSmall]="true"
aria-hidden="true"
></clr-spinner> ></clr-spinner>
<span class="tooltip-content">Reject</span> <span class="tooltip-content">Reject</span>
</a> </a>
@@ -81,7 +109,11 @@
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue" class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="getTable(approveItem.tableId)" (click)="getTable(approveItem.tableId)"
> >
<clr-icon shape="code" size="28"></clr-icon> <clr-icon
shape="code"
size="28"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content">Go to staged data screen</span> <span class="tooltip-content">Go to staged data screen</span>
</a> </a>
</div> </div>

View File

@@ -8,6 +8,7 @@ import { SasStoreService } from '../../services/sas-store.service'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { SasService } from '../../services/sas.service' import { SasService } from '../../services/sas.service'
import { EventService } from '../../services/event.service' import { EventService } from '../../services/event.service'
import { ClrDatagridStringFilterInterface } from '@clr/angular'
interface ApproveData { interface ApproveData {
tableId: string tableId: string
@@ -19,6 +20,32 @@ interface ApproveData {
rejectLoading?: boolean rejectLoading?: boolean
} }
class SubmitterFilter implements ClrDatagridStringFilterInterface<ApproveData> {
accepts(data: ApproveData, search: string): boolean {
return data.submitter.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class BaseTableFilter implements ClrDatagridStringFilterInterface<ApproveData> {
accepts(data: ApproveData, search: string): boolean {
return data.baseTable.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class SubmittedFilter implements ClrDatagridStringFilterInterface<ApproveData> {
accepts(data: ApproveData, search: string): boolean {
return data.submitted.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class SubmitReasonFilter
implements ClrDatagridStringFilterInterface<ApproveData>
{
accepts(data: ApproveData, search: string): boolean {
return data.submitReason.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
@Component({ @Component({
selector: 'app-approve', selector: 'app-approve',
templateUrl: './approve.component.html', templateUrl: './approve.component.html',
@@ -35,6 +62,12 @@ export class ApproveComponent implements OnInit {
public loaded: boolean = false public loaded: boolean = false
public itemsNum: number = 10 public itemsNum: number = 10
// Filter instances for datagrid accessibility
public submitterFilter = new SubmitterFilter()
public baseTableFilter = new BaseTableFilter()
public submittedFilter = new SubmittedFilter()
public submitReasonFilter = new SubmitReasonFilter()
constructor( constructor(
private sasStoreService: SasStoreService, private sasStoreService: SasStoreService,
private eventService: EventService, private eventService: EventService,

View File

@@ -85,16 +85,48 @@
class="datagrid-history datagrid-custom-footer" class="datagrid-history datagrid-custom-footer"
*ngIf="loaded" *ngIf="loaded"
> >
<clr-dg-column [clrDgField]="'basetable'">BASE_TABLE</clr-dg-column> <clr-dg-column [clrDgField]="'basetable'">
<clr-dg-column [clrDgField]="'status'">STATUS</clr-dg-column> BASE_TABLE
<clr-dg-column [clrDgField]="'submitter'">SUBMITTER</clr-dg-column> <clr-dg-string-filter
<clr-dg-column [clrDgField]="'submittedReason'" [clrDgStringFilter]="baseTableFilter"
>SUBMIT REASON</clr-dg-column aria-label="Filter base table"
> ></clr-dg-string-filter>
<clr-dg-column [clrDgField]="'submitted'">SUBMITTED</clr-dg-column> </clr-dg-column>
<clr-dg-column [clrDgField]="'reviewed'" <clr-dg-column [clrDgField]="'status'">
>APPROVED / REJECTED</clr-dg-column STATUS
> <clr-dg-string-filter
[clrDgStringFilter]="statusFilter"
aria-label="Filter status"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
<clr-dg-string-filter
[clrDgStringFilter]="submitterFilter"
aria-label="Filter submitter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submittedReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'reviewed'">
APPROVED / REJECTED
<clr-dg-string-filter
[clrDgStringFilter]="reviewedFilter"
aria-label="Filter reviewed date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column> <clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row <clr-dg-row

View File

@@ -8,6 +8,55 @@ import {
EventService, EventService,
SasService SasService
} from 'src/app/services' } from 'src/app/services'
import { ClrDatagridStringFilterInterface } from '@clr/angular'
interface HistoryData {
tableId: string
basetable: string
status: string
submitter: string
submittedReason: string
submitted: string
reviewed: string
}
class BaseTableFilter implements ClrDatagridStringFilterInterface<HistoryData> {
accepts(data: HistoryData, search: string): boolean {
return data.basetable.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class StatusFilter implements ClrDatagridStringFilterInterface<HistoryData> {
accepts(data: HistoryData, search: string): boolean {
return data.status.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class SubmitterFilter implements ClrDatagridStringFilterInterface<HistoryData> {
accepts(data: HistoryData, search: string): boolean {
return data.submitter.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class SubmitReasonFilter
implements ClrDatagridStringFilterInterface<HistoryData>
{
accepts(data: HistoryData, search: string): boolean {
return data.submittedReason.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class SubmittedFilter implements ClrDatagridStringFilterInterface<HistoryData> {
accepts(data: HistoryData, search: string): boolean {
return data.submitted.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class ReviewedFilter implements ClrDatagridStringFilterInterface<HistoryData> {
accepts(data: HistoryData, search: string): boolean {
return data.reviewed.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
@Component({ @Component({
selector: 'app-history', selector: 'app-history',
@@ -29,6 +78,14 @@ export class HistoryComponent implements OnInit {
public approveData: any = {} public approveData: any = {}
public sasjsConfig: SASjsConfig = new SASjsConfig() public sasjsConfig: SASjsConfig = new SASjsConfig()
// Filter instances for datagrid accessibility
public baseTableFilter = new BaseTableFilter()
public statusFilter = new StatusFilter()
public submitterFilter = new SubmitterFilter()
public submitReasonFilter = new SubmitReasonFilter()
public submittedFilter = new SubmittedFilter()
public reviewedFilter = new ReviewedFilter()
public histParams: { HIST: number; STARTROW: number; NOBS: number } = { public histParams: { HIST: number; STARTROW: number; NOBS: number } = {
HIST: 0, HIST: 0,
STARTROW: 1, STARTROW: 1,

View File

@@ -44,10 +44,20 @@
<div *ngIf="submitterList && remained !== 0"> <div *ngIf="submitterList && remained !== 0">
<clr-datagrid class="datagrid-compact datagrid-custom-footer"> <clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column>BASE TABLE</clr-dg-column> <clr-dg-column>BASE TABLE</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">SUBMITTED</clr-dg-column> <clr-dg-column [clrDgField]="'submitted'">
<clr-dg-column [clrDgField]="'submitReason'" SUBMITTED
>SUBMIT REASON</clr-dg-column <clr-dg-string-filter
> [clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column class="d-flex justify-content-center" <clr-dg-column class="d-flex justify-content-center"
>ACTION</clr-dg-column >ACTION</clr-dg-column
> >

View File

@@ -7,6 +7,7 @@ import {
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { SasStoreService, EventService, SasService } from '../../services' import { SasStoreService, EventService, SasService } from '../../services'
import { ClrDatagridStringFilterInterface } from '@clr/angular'
interface SubmitterData { interface SubmitterData {
tableId: string tableId: string
@@ -16,6 +17,22 @@ interface SubmitterData {
approver: string approver: string
} }
class SubmittedFilter
implements ClrDatagridStringFilterInterface<SubmitterData>
{
accepts(data: SubmitterData, search: string): boolean {
return data.submitted.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
class SubmitReasonFilter
implements ClrDatagridStringFilterInterface<SubmitterData>
{
accepts(data: SubmitterData, search: string): boolean {
return data.submitReason.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
}
@Component({ @Component({
selector: 'app-submitter', selector: 'app-submitter',
templateUrl: './submitter.component.html', templateUrl: './submitter.component.html',
@@ -37,6 +54,10 @@ export class SubmitterComponent implements OnInit, AfterViewInit {
private _readySub!: Subscription private _readySub!: Subscription
private _backToSub!: Subscription private _backToSub!: Subscription
// Filter instances for datagrid accessibility
public submittedFilter = new SubmittedFilter()
public submitReasonFilter = new SubmitReasonFilter()
constructor( constructor(
private sasStoreService: SasStoreService, private sasStoreService: SasStoreService,
private eventService: EventService, private eventService: EventService,

View File

@@ -46,6 +46,10 @@
rejected: tableDetails?.REVIEW_STATUS_ID === 'REJECTED', rejected: tableDetails?.REVIEW_STATUS_ID === 'REJECTED',
accepted: tableDetails?.REVIEW_STATUS_ID === 'APPROVED' accepted: tableDetails?.REVIEW_STATUS_ID === 'APPROVED'
}" }"
[attr.aria-label]="
'Review status: ' + tableDetails?.REVIEW_STATUS_ID
"
role="status"
> >
{{ tableDetails?.REVIEW_STATUS_ID }} {{ tableDetails?.REVIEW_STATUS_ID }}
</span> </span>
@@ -61,6 +65,7 @@
class="btn btn-sm btn-outline text-center mr-5i" class="btn btn-sm btn-outline text-center mr-5i"
(click)="viewerTableScreen()" (click)="viewerTableScreen()"
[disabled]="revertingChanges" [disabled]="revertingChanges"
aria-label="View base table"
> >
View base table View base table
</button> </button>
@@ -74,6 +79,7 @@
" "
(click)="approveTableScreen()" (click)="approveTableScreen()"
[disabled]="revertingChanges" [disabled]="revertingChanges"
aria-label="Approve table"
> >
Approve Approve
</button> </button>
@@ -81,14 +87,16 @@
class="btn btn-sm btn-info-outline text-center mr-5i" class="btn btn-sm btn-info-outline text-center mr-5i"
(click)="goBack()" (click)="goBack()"
[disabled]="revertingChanges" [disabled]="revertingChanges"
aria-label="Edit base table"
> >
Edit base table Edit base table
</button> </button>
<button <button
class="btn btn-sm btn-success text-center mr-5i min-w-0" class="btn btn-sm btn-success text-center mr-5i min-w-0"
(click)="download(tableDetails?.TABLE_ID)" (click)="download(tableDetails?.TABLE_ID)"
aria-label="Download audit file"
> >
<clr-icon shape="download"></clr-icon> <clr-icon shape="download" aria-hidden="true"></clr-icon>
</button> </button>
<clr-tooltip> <clr-tooltip>
@@ -98,6 +106,7 @@
clrTooltipTrigger clrTooltipTrigger
[clrLoading]="revertingChanges" [clrLoading]="revertingChanges"
class="btn btn-sm btn-danger text-center mt-20" class="btn btn-sm btn-danger text-center mt-20"
aria-label="Revert this and all subsequent changes"
> >
REVERT REVERT
@@ -128,6 +137,8 @@
[afterGetColHeader]="hotTable.afterGetColHeader" [afterGetColHeader]="hotTable.afterGetColHeader"
stretchH="all" stretchH="all"
[cells]="hotTable.cells" [cells]="hotTable.cells"
[settings]="hotTable.settings"
aria-label="Staged data table"
> >
<!--[licenseKey]=null--> <!--[licenseKey]=null-->
</hot-table> </hot-table>

View File

@@ -1,4 +1,10 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core' import {
Component,
OnInit,
ViewEncapsulation,
ViewChild,
AfterViewInit
} from '@angular/core'
import { SasStoreService } from '../services/sas-store.service' import { SasStoreService } from '../services/sas-store.service'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
@@ -19,7 +25,7 @@ import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapper
}, },
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class StageComponent implements OnInit { export class StageComponent implements OnInit, AfterViewInit {
public table_id: any public table_id: any
public jsParams: any public jsParams: any
public keysArray: any public keysArray: any
@@ -32,12 +38,26 @@ export class StageComponent implements OnInit {
colHeaders: [], colHeaders: [],
columns: [], columns: [],
height: 500, height: 500,
settings: {}, settings: {
// Disable problematic ARIA attributes that cause accessibility issues
ariaTags: false,
// Use grid role instead of treegrid for better accessibility
tableClassName: 'htCenter',
// Disable focus management to avoid focus catcher issues
outsideClickDeselects: false,
// Use simpler accessibility mode
autoWrapRow: false,
autoWrapCol: false
},
licenseKey: undefined, licenseKey: undefined,
maxRows: this.licenceState.value.stage_rows_allowed || Infinity, maxRows: this.licenceState.value.stage_rows_allowed || Infinity,
afterGetColHeader: (column, th, headerLevel) => { afterGetColHeader: (column, th, headerLevel) => {
// Dark mode // Dark mode
th.classList.add(globals.handsontable.darkTableHeaderClass) th.classList.add(globals.handsontable.darkTableHeaderClass)
},
afterInit: () => {
// Fix accessibility issues with focus catcher inputs
this.fixFocusCatcherAccessibility()
} }
} }
@@ -169,6 +189,13 @@ export class StageComponent implements OnInit {
} }
} }
ngAfterViewInit() {
// Additional accessibility fixes after view is initialized
setTimeout(() => {
this.fixFocusCatcherAccessibility()
}, 500)
}
revertChanges() { revertChanges() {
this.revertingChanges = true this.revertingChanges = true
@@ -204,4 +231,27 @@ export class StageComponent implements OnInit {
} }
}, 200) }, 200)
} }
private fixFocusCatcherAccessibility() {
// Add labels to focus catcher inputs to fix accessibility issues
setTimeout(() => {
const focusCatchers = document.querySelectorAll('.htFocusCatcher')
focusCatchers.forEach((input: any, index: number) => {
if (input) {
// Add proper accessibility attributes
input.setAttribute('aria-label', `Table focus catcher ${index + 1}`)
input.setAttribute('aria-hidden', 'true')
input.setAttribute('tabindex', '-1')
input.setAttribute('role', 'presentation')
// Add a hidden label element
const label = document.createElement('label')
label.setAttribute('for', input.id || `htFocusCatcher${index}`)
label.setAttribute('aria-hidden', 'true')
label.style.display = 'none'
label.textContent = `Table focus catcher ${index + 1}`
input.parentNode?.insertBefore(label, input)
}
})
}, 100)
}
} }

View File

@@ -622,6 +622,7 @@
<div *ngIf="!noData && !noDataReqErr && table" class="clr-flex-1"> <div *ngIf="!noData && !noDataReqErr && table" class="clr-flex-1">
<hot-table <hot-table
#hotInstance
hotId="hotInstance" hotId="hotInstance"
id="hotTable" id="hotTable"
className="htDark" className="htDark"

View File

@@ -3,9 +3,11 @@ import {
AfterContentInit, AfterContentInit,
ChangeDetectorRef, ChangeDetectorRef,
AfterViewInit, AfterViewInit,
OnDestroy,
ViewChildren, ViewChildren,
QueryList, QueryList,
ViewEncapsulation ViewEncapsulation,
ViewChild
} from '@angular/core' } from '@angular/core'
import { SasStoreService } from '../services/sas-store.service' import { SasStoreService } from '../services/sas-store.service'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
@@ -49,10 +51,15 @@ import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapper
}, },
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class ViewerComponent implements AfterContentInit, AfterViewInit { export class ViewerComponent
implements AfterContentInit, AfterViewInit, OnDestroy
{
@ViewChildren('queryFilter') @ViewChildren('queryFilter')
queryFilterCompList: QueryList<QueryComponent> = new QueryList() queryFilterCompList: QueryList<QueryComponent> = new QueryList()
@ViewChild('hotInstance', { static: false })
hotInstanceViewChild!: Handsontable
public libraries!: Array<any> public libraries!: Array<any>
public librariesPaging: boolean = false public librariesPaging: boolean = false
public librariesSearch: string = '' public librariesSearch: string = ''
@@ -107,11 +114,14 @@ export class ViewerComponent implements AfterContentInit, AfterViewInit {
public licenceState = this.licenceService.licenceState public licenceState = this.licenceService.licenceState
public Infinity = Infinity public Infinity = Infinity
private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined
public hotTable: HotTableInterface = { public hotTable: HotTableInterface = {
data: [], data: [],
colHeaders: [], colHeaders: [],
columns: [], columns: [],
height: '100%', height: 'calc(100vh - 182px)',
maxRows: this.licenceState.value.viewer_rows_allowed || Infinity, maxRows: this.licenceState.value.viewer_rows_allowed || Infinity,
settings: {}, settings: {},
licenseKey: undefined, licenseKey: undefined,
@@ -530,7 +540,6 @@ export class ViewerComponent implements AfterContentInit, AfterViewInit {
`#search_${library.LIBRARYREF}` `#search_${library.LIBRARYREF}`
) )
this.loggerService.log('[libTreeSearchInput]', libTreeSearchInput)
if (libTreeSearchInput) libTreeSearchInput.focus() if (libTreeSearchInput) libTreeSearchInput.focus()
if (library && library.libinfo) this.libinfo = library.libinfo if (library && library.libinfo) this.libinfo = library.libinfo
@@ -819,28 +828,35 @@ export class ViewerComponent implements AfterContentInit, AfterViewInit {
this.versions = res.versions || [] this.versions = res.versions || []
this.setDSNote() this.setDSNote()
this.queryText = res.sasparams[0].FILTER_TEXT this.queryText = res.sasparams[0].FILTER_TEXT
let columns: any[] = []
let colArr = []
for (let key in res.viewdata[0]) { // Initialize columns only if we have data
if (key) { if (res.viewdata && res.viewdata.length > 0) {
colArr.push(key) let columns: any[] = []
let colArr = []
for (let key in res.viewdata[0]) {
if (key) {
colArr.push(key)
}
} }
for (let index = 0; index < colArr.length; index++) {
columns.push({ data: colArr[index] })
}
this.hotTable.colHeaders = colArr
this.hotTable.columns = columns
} else {
// Set empty arrays if no data
this.hotTable.colHeaders = []
this.hotTable.columns = []
} }
for (let index = 0; index < colArr.length; index++) { // Set cells function
columns.push({ data: colArr[index] }) this.hotTable.cells = () => {
return { readOnly: true }
} }
let cells = function () {
let cellProperties = { readOnly: true }
return cellProperties
}
this.hotTable.colHeaders = colArr
this.hotTable.columns = columns
this.hotTable.cells = cells
this.tableFlag = false this.tableFlag = false
let ds = [] let ds = []
ds = libDataset.split('.') ds = libDataset.split('.')
@@ -911,6 +927,11 @@ export class ViewerComponent implements AfterContentInit, AfterViewInit {
//That is intorduced by HOT update //That is intorduced by HOT update
if (!this.noData && !this.noDataReqErr && libDataset) this.setupHot() if (!this.noData && !this.noDataReqErr && libDataset) this.setupHot()
// Fix ARIA accessibility issues after data loading
setTimeout(() => {
this.fixAriaAccessibility()
}, 1500)
/** /**
* This is hacky fix for closing the nav drodpwon, when clicking on the handsontable area. * This is hacky fix for closing the nav drodpwon, when clicking on the handsontable area.
* Without it, hot table does not steal focus, so it doesn't close the nav. * Without it, hot table does not steal focus, so it doesn't close the nav.
@@ -1065,7 +1086,7 @@ export class ViewerComponent implements AfterContentInit, AfterViewInit {
if (this.hotInstance) { if (this.hotInstance) {
this.hotInstance.updateSettings({ this.hotInstance.updateSettings({
height: this.hotTable.height, height: this.hotTable.height,
modifyColWidth: function (width: any, col: any) { modifyColWidth: (width: any, col: any) => {
if (width > 500) return 500 if (width > 500) return 500
else return width else return width
}, },
@@ -1081,8 +1102,26 @@ export class ViewerComponent implements AfterContentInit, AfterViewInit {
th.classList.add(globals.handsontable.darkTableHeaderClass) th.classList.add(globals.handsontable.darkTableHeaderClass)
} }
}) })
// Add hooks for accessibility fixes
this.hotInstance.addHook('afterRender', () => {
// Fix ARIA accessibility issues after each render
this.fixAriaAccessibility()
})
this.hotInstance.addHook('afterChange', () => {
// Fix ARIA accessibility issues after any data change
setTimeout(() => {
this.fixAriaAccessibility()
}, 50)
})
} }
} }
// Fix ARIA accessibility issues after table setup
setTimeout(() => {
this.fixAriaAccessibility()
}, 500)
}, 1000) }, 1000)
} }
@@ -1148,7 +1187,173 @@ export class ViewerComponent implements AfterContentInit, AfterViewInit {
} }
} }
ngAfterViewInit() {} ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 1000)
}
ngOnDestroy() {
// Clean up the MutationObserver
if (this.ariaObserver) {
this.ariaObserver.disconnect()
this.ariaObserver = undefined
}
// Clean up the interval
if (this.ariaCheckInterval) {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
}
/**
* Fixes ARIA accessibility issues in the Handsontable component
* This addresses the accessibility report issues with treegrid and presentation roles
*/
private fixAriaAccessibility() {
// Use a more aggressive approach to find and fix all ARIA issues
const fixAriaIssues = () => {
// Specifically target Handsontable wrapper elements that are causing issues
const hotWrappers = document.querySelectorAll(
'.ht-wrapper, .wtHolder, [id^="ht_"]'
)
hotWrappers.forEach((wrapper) => {
// Remove problematic ARIA attributes from Handsontable wrappers
wrapper.removeAttribute('role')
wrapper.removeAttribute('aria-rowcount')
wrapper.removeAttribute('aria-colcount')
wrapper.removeAttribute('aria-multiselectable')
})
// Find all elements with problematic ARIA roles in the entire document
const allTreegridElements = document.querySelectorAll('[role="treegrid"]')
const allPresentationElements = document.querySelectorAll(
'[role="presentation"]'
)
// Fix treegrid role issues - remove them completely as they're causing problems
allTreegridElements.forEach((element) => {
element.removeAttribute('role')
element.removeAttribute('aria-rowcount')
element.removeAttribute('aria-colcount')
element.removeAttribute('aria-multiselectable')
})
// Fix presentation role issues - remove them if they contain interactive elements
allPresentationElements.forEach((element) => {
const hasInteractiveChildren =
element.querySelectorAll(
'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
).length > 0
if (hasInteractiveChildren) {
element.removeAttribute('role')
}
})
// Also fix any elements with aria-rowcount="-1" which is problematic
const negativeRowCountElements = document.querySelectorAll(
'[aria-rowcount="-1"]'
)
negativeRowCountElements.forEach((element) => {
element.removeAttribute('aria-rowcount')
})
// Ensure proper table structure
const tableElements = document.querySelectorAll('table')
tableElements.forEach((table) => {
if (!table.getAttribute('role')) {
table.setAttribute('role', 'table')
}
// Ensure table headers have proper scope
const headerCells = table.querySelectorAll('th')
headerCells.forEach((th) => {
if (!th.getAttribute('scope')) {
th.setAttribute('scope', 'col')
}
})
})
// Add proper ARIA labels to interactive elements
const interactiveElements = document.querySelectorAll(
'button, input, select, textarea, [contenteditable]'
)
interactiveElements.forEach((element) => {
if (
!element.getAttribute('aria-label') &&
!element.getAttribute('aria-labelledby')
) {
const textContent = element.textContent?.trim()
if (textContent) {
element.setAttribute('aria-label', textContent)
}
}
})
}
// Run the fix immediately
fixAriaIssues()
// Run it again after a short delay to catch any dynamically created elements
setTimeout(fixAriaIssues, 100)
setTimeout(fixAriaIssues, 500)
setTimeout(fixAriaIssues, 1000)
setTimeout(fixAriaIssues, 2000)
// Set up a periodic check to ensure accessibility fixes are maintained
if (!this.ariaCheckInterval) {
this.ariaCheckInterval = setInterval(fixAriaIssues, 3000)
}
// Set up a MutationObserver to continuously monitor for new problematic elements
if (!this.ariaObserver) {
this.ariaObserver = new MutationObserver((mutations) => {
let shouldFix = false
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
(mutation.attributeName === 'role' ||
mutation.attributeName === 'aria-rowcount')
) {
shouldFix = true
}
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
if (
element.hasAttribute('role') ||
element.hasAttribute('aria-rowcount')
) {
shouldFix = true
}
}
})
}
})
if (shouldFix) {
setTimeout(fixAriaIssues, 50)
}
})
// Start observing the entire document for changes
this.ariaObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'role',
'aria-rowcount',
'aria-colcount',
'aria-multiselectable'
]
})
}
}
async ngAfterContentInit() { async ngAfterContentInit() {
if (this.hotTable.data.length > 0) { if (this.hotTable.data.length > 0) {

View File

@@ -1042,12 +1042,12 @@ app-approve {
// HISTORY.COMPONENT // HISTORY.COMPONENT
app-history { app-history {
.rejected { .rejected {
color: #f83126; color: #92201a;
font-weight: bold font-weight: bold
} }
.accepted { .accepted {
color: #3fc424; color: #105c26;
font-weight: bold font-weight: bold
} }
@@ -1206,6 +1206,8 @@ app-viewer {
} }
hot-table { hot-table {
height: calc(100vh - 200px);
.handsontable tbody th.ht__highlight, .handsontable thead th.ht__highlight { .handsontable tbody th.ht__highlight, .handsontable thead th.ht__highlight {
&.primaryKeyHeaderStyle { &.primaryKeyHeaderStyle {
background-color: #306b00b0 !important; background-color: #306b00b0 !important;
@@ -3442,6 +3444,10 @@ app-approve-details {
width: 175px width: 175px
} }
#rejectBtn {
background-color: #a62f16 !important;
}
.formatted-values-toggle { .formatted-values-toggle {
min-width: 75px min-width: 75px
} }
@@ -3630,12 +3636,12 @@ app-excel-password-modal {
// STAGE.COMPONENT // STAGE.COMPONENT
app-stage { app-stage {
.rejected { .rejected {
color: #f83126; color: #92201a;
font-weight: bold font-weight: bold
} }
.accepted { .accepted {
color: #3fc424; color: #105c26;
font-weight: bold font-weight: bold
} }
@@ -3644,6 +3650,25 @@ app-stage {
margin-top:10px; margin-top:10px;
color: #007cbb; color: #007cbb;
} }
// Accessibility fixes for handsontable focus catchers
.htFocusCatcher {
// Hide from screen readers but maintain functionality
position: absolute !important;
left: -9999px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: 0 !important;
margin: -1px !important;
padding: 0 !important;
// Ensure it's not focusable by screen readers
&:focus {
outline: none !important;
}
}
} }
body[cds-theme="dark"] { body[cds-theme="dark"] {