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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint-baseline.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"root": 1619,
"root": 1516,
"test-app": 4
}
9 changes: 7 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ const recommendedTypeScriptWarnings = asWarnings([
...angular.configs.tsRecommended,
]);

const accessibilityWarnings = asWarnings(angular.configs.templateAccessibility);
// Template accessibility debt (see #583) has been fully resolved: every rule
// in `angular.configs.templateAccessibility` currently has 0 findings across
Comment thread
fpigeonjr marked this conversation as resolved.
// both workspaces. Per #580's promotion policy ("resolved rule categories are
// promoted from warnings to errors"), these are enforced as errors directly
// from the plugin's own recommended severities rather than downgraded to
// warnings, so any future regression fails the build immediately instead of
// silently inflating the warning baseline.

// Bans the RxJS 5 "unbound operator" call pattern (e.g.
// `first.call(observable).subscribe(...)`), which throws
Expand Down Expand Up @@ -64,6 +70,5 @@ export default tseslint.config(
{
files: ["**/*.html"],
extends: angular.configs.templateAccessibility,
rules: accessibilityWarnings,
}
);
5 changes: 5 additions & 0 deletions src/ui-kit/components/comments/comment/comment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,9 @@ describe("The Sam Comment component", () => {
const imageEl = fixture.debugElement.query(By.css("img")).nativeElement;
expect(imageEl.src).toBe(comment.image);
});

it("Should provide a text alternative for the image (decorative, since the figcaption already names the user)", function () {
const imageEl = fixture.debugElement.query(By.css("img")).nativeElement;
expect(imageEl.getAttribute("alt")).toBe("");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<header>
<figure>
<span *ngIf="!comment?.image" class="fa fa-3x fa-user"></span>
<img *ngIf="comment?.image" [src]="comment?.image" />
<img *ngIf="comment?.image" [src]="comment?.image" alt="" />
<figcaption class="usa-sr-only">
Icon for user {{comment.username}}
</figcaption>
Expand Down
20 changes: 20 additions & 0 deletions src/ui-kit/components/header-next/header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,26 @@ describe("SamHeaderNextComponent", () => {
expect(logoElement.getAttribute("src")).toBe(logoPath);
});

it("should mark the mobile nav overlay as presentational (click-to-close only, not independently focusable)", () => {
fixture.detectChanges();

const overlay: HTMLElement =
fixture.nativeElement.querySelector(".usa-overlay");
expect(overlay.getAttribute("role")).toBe("presentation");
});

it("should close the mobile nav when the overlay is clicked", () => {
component.mobileNavActive = true;
fixture.detectChanges();

const overlay: HTMLElement =
fixture.nativeElement.querySelector(".usa-overlay");
overlay.click();
fixture.detectChanges();

expect(component.mobileNavActive).toBe(false);
});

it.skip("should add notifications indicator to menu button", () => {
component.notifications = true;
fixture.detectChanges();
Expand Down
2 changes: 2 additions & 0 deletions src/ui-kit/components/header-next/header.template.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<div
class="usa-overlay"
role="presentation"
[class.is-visible]="mobileNavActive"
(click)="closeMobileNav()"
></div>
Expand Down Expand Up @@ -33,6 +34,7 @@
<nav
aria-label="Primary navigation"
class="usa-nav"
tabindex="-1"
(keydown.esc)="closeMobileNav()"
(animationend)="navAnimationEnd()"
[class.is-visible]="mobileNavActive"
Expand Down
5 changes: 5 additions & 0 deletions src/ui-kit/components/tabs/tabs.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ export class SamTabComponent {
<ng-container *ngFor="let tab of tabs; let i = index">
<a
class="item"
[attr.tabindex]="tab.disabled ? -1 : 0"
(click)="selectTab(tab, i)"
(keydown.enter)="selectTab(tab, i)"
[ngClass]="{ active: tab.active, disabled: tab.disabled }"
*ngIf="!tab.float"
>
Expand Down Expand Up @@ -173,6 +175,9 @@ export class SamTabsComponent implements AfterContentInit {
}

selectTab(tab: SamTabComponent, index) {
if (tab.disabled) {
return;
}
this.tabs.forEach((t) => (t.active = false));
tab.active = true;
this.active = index;
Expand Down
27 changes: 27 additions & 0 deletions src/ui-kit/components/tabs/tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,32 @@ describe("The Sam Tabs component", () => {
fixture.detectChanges();
expect(component.comp.active).toBe(0);
});

it("should select a tab via keyboard (Enter) same as click", function () {
const tabLinks = fixture.debugElement.queryAll(By.css("a.item"));
const secondTabLink = tabLinks[1].nativeElement;
secondTabLink.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter" })
);
fixture.detectChanges();
expect(component.comp.active).toBe(1);
});

it("should not activate a disabled tab via click or keyboard, and should remove it from the tab order", function () {
component.comp.tabs.toArray()[1].disabled = true;
fixture.detectChanges();

const tabLinks = fixture.debugElement.queryAll(By.css("a.item"));
const disabledTabLink = tabLinks[1].nativeElement;
expect(disabledTabLink.getAttribute("tabindex")).toBe("-1");

disabledTabLink.dispatchEvent(new MouseEvent("click"));
disabledTabLink.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter" })
);
fixture.detectChanges();

expect(component.comp.active).toBe(0);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@
<span class="close">
<span
role="button"
tabindex="0"
Comment thread
fpigeonjr marked this conversation as resolved.
class="fa fa-times"
aria-label="Clear input"
aria-hidden="false"
(click)="clearInput()"
(keyup.enter)="clearInput()"
(keydown.space)="$event.preventDefault(); clearInput()"
>
</span>
</span>
Expand All @@ -58,10 +60,13 @@
<ng-container *ngIf="results && results.length > 0">
<li
role="option"
tabindex="-1"
[attr.aria-selected]="!!result['highlighted']"
*ngFor="let result of results; let i = index"
(mouseenter)="listItemHover(i)"
[class]="result['highlighted'] ? 'selected-item' : ''"
(click)="selectItem(result)"
(keydown.enter)="selectItem(result)"
>
<ng-container
*ngIf="itemTemplate"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,62 @@ describe("SamHierarchicalAutocompleteComponent", () => {
);
expect(listAfter).toBeFalsy();
}));

it("marks the highlighted result option as aria-selected", fakeAsync(() => {
component.inputFocusHandler();
fixture.detectChanges();
tick();
fixture.detectChanges();
const options = fixture.debugElement.queryAll(By.css('li[role="option"]'));
expect(options[0].nativeElement.getAttribute("aria-selected")).toBe("true");
expect(options[1].nativeElement.getAttribute("aria-selected")).toBe(
"false"
);
}));

it("selects a result via keyboard (Enter) same as click", fakeAsync(() => {
component.inputFocusHandler();
fixture.detectChanges();
tick();
fixture.detectChanges();
const firstOption = fixture.debugElement.query(
By.css('li[role="option"]')
).nativeElement;
firstOption.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
fixture.detectChanges();
expect(component.model.getItems().length).toBe(1);
}));

it("clears the input via keyboard (Enter) on the clear button, same as click", fakeAsync(() => {
component.inputFocusHandler();
fixture.detectChanges();
tick();
fixture.detectChanges();
const clearButton = fixture.debugElement.query(
By.css('span[role="button"]')
).nativeElement;
component.inputValue = "id";
fixture.detectChanges();
clearButton.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter" }));
fixture.detectChanges();
expect(component.inputValue).toBe("");
}));

it("clears the input via keyboard (Space) on the clear button, same as click, and prevents page scroll", fakeAsync(() => {
component.inputFocusHandler();
fixture.detectChanges();
tick();
fixture.detectChanges();
const clearButton = fixture.debugElement.query(
By.css('span[role="button"]')
).nativeElement;
component.inputValue = "id";
fixture.detectChanges();
const spaceEvent = new KeyboardEvent("keydown", { key: " " });
const preventDefaultSpy = vi.spyOn(spaceEvent, "preventDefault");
clearButton.dispatchEvent(spaceEvent);
fixture.detectChanges();
expect(component.inputValue).toBe("");
expect(preventDefaultSpy).toHaveBeenCalled();
}));
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
role="listbox"
aria-live="polite"
>
<li role="option" *ngFor="let result of model.getItems(); let i = index">
<li
role="option"
aria-selected="true"
*ngFor="let result of model.getItems(); let i = index"
>
<div
[attr.class]="
disabled ? 'selected-item selected-item-disabled' : 'selected-item'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,20 @@ describe("SamHierarchicalSelectedResultComponent", () => {
const list = fixture.debugElement.query(By.css(".resultsList"));
expect(list.nativeElement.children.length).toBe(1);
});

it("marks each selected-result option as aria-selected", () => {
component.model.addItem(
{
id: "1",
parentId: null,
name: "Level 1",
subtext: "id 1",
type: "Level 1",
},
"id"
);
fixture.detectChanges();
const option = fixture.debugElement.query(By.css('li[role="option"]'));
expect(option.nativeElement.getAttribute("aria-selected")).toBe("true");
});
});
7 changes: 4 additions & 3 deletions src/ui-kit/experimental/listbox/listbox.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[errorMessage]="errorMessage"
[required]="required"
>
<div id="listboxgroup-lable" (keydown)="onKeyDown($event)">
<div id="listboxgroup-lable" tabindex="-1" (keydown)="onKeyDown($event)">
<ul
role="listboxgroup"
tabindex="0"
Expand All @@ -22,7 +22,8 @@
[class]="option['highlighted'] ? 'selected-item' : ''"
*ngFor="let option of options; let i = index"
role="option"
aria-checked="false"
[attr.aria-selected]="isChecked(option.value)"
[attr.aria-checked]="isChecked(option.value)"
>
<input
[disabled]="option.disabled || disabled ? 'disabled' : null"
Expand All @@ -31,7 +32,7 @@
[attr.type]="optionsMode"
[attr.aria-label]="option.name"
[required]="option.required"
[checked]="option.checked"
[checked]="isChecked(option.value)"
name="option"
(change)="onChecked($event, option)"
/>
Expand Down
38 changes: 38 additions & 0 deletions src/ui-kit/experimental/listbox/listbox.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,42 @@ describe("SamListBoxComponent", () => {
expect(component.isChecked(options[2].value)).toBe(true);
expect(component.isChecked(options[3].value)).toBe(false);
});

it("marks each listbox option's aria-selected/aria-checked from the canonical model selection, kept in sync as options are (un)checked", () => {
component.options = options;
fixture.detectChanges();
const listItems = fixture.debugElement.queryAll(
By.css('li[role="option"]')
);
expect(listItems[1].nativeElement.getAttribute("aria-selected")).toBe(
"false"
);
expect(listItems[1].nativeElement.getAttribute("aria-checked")).toBe(
"false"
);

const input1: HTMLInputElement =
listItems[1].nativeElement.querySelector("input");
input1.checked = true;
input1.dispatchEvent(new Event("change"));
fixture.detectChanges();

expect(listItems[1].nativeElement.getAttribute("aria-selected")).toBe(
"true"
);
expect(listItems[1].nativeElement.getAttribute("aria-checked")).toBe(
"true"
);

input1.checked = false;
input1.dispatchEvent(new Event("change"));
fixture.detectChanges();

expect(listItems[1].nativeElement.getAttribute("aria-selected")).toBe(
"false"
);
expect(listItems[1].nativeElement.getAttribute("aria-checked")).toBe(
"false"
);
});
});
11 changes: 10 additions & 1 deletion src/ui-kit/experimental/listbox/listbox.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,16 @@ export class SamListBoxComponent implements ControlValueAccessor {
}

isChecked(value) {
return this.model.indexOf(value) !== -1;
// `model` may contain either raw option values (the
// ControlValueAccessor/writeValue contract) or, via onChecked's
// insertion path below, the option objects themselves. Treat both
// shapes as "the same canonical selection value" so the rendered
// aria-selected/aria-checked state (bound to this method) and the
// native checkbox/radio `checked` state never disagree, regardless of
// how selection was set.
return this.model.some(
(entry) => entry === value || (entry && entry.value === value)
);
}

/**
Expand Down
Loading
Loading