Skip to content

Commit

Permalink
feat(description): accessible collapsible tree
Browse files Browse the repository at this point in the history
refactor metadata to be tree-shaped
refactor humans.txt template to take into account new shape
remove description line component, merge into description
refactor description component to act as composite
add additional tree models
add accessibility features
refactor tests with simple line use case
  • Loading branch information
davidlj95 committed Sep 21, 2023
1 parent 4230c28 commit b4bb892
Show file tree
Hide file tree
Showing 12 changed files with 694 additions and 173 deletions.
2 changes: 1 addition & 1 deletion src/app/about/about.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ <h1>
<app-contact-social-icons></app-contact-social-icons>
</div>
</div>
<app-description></app-description>
<app-description [node]="descriptionTree"></app-description>
</section>
4 changes: 4 additions & 0 deletions src/app/about/about.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,9 @@
}
}
}

app-description {
padding: paddings.$m;
}
}
}
4 changes: 4 additions & 0 deletions src/app/about/about.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, Inject } from '@angular/core';
import { METADATA } from '../common/injection-tokens';
import { Metadata } from '../metadata';
import { ExtendedDescriptionTreeNode } from './description/extended-description-tree-node';

@Component({
selector: 'app-about',
Expand All @@ -10,6 +11,9 @@ import { Metadata } from '../metadata';
export class AboutComponent {
public realName = this.metadata.realName;
public nickname = this.metadata.nickname;
public descriptionTree = ExtendedDescriptionTreeNode.fromNode(
this.metadata.descriptionTree,
);

constructor(
@Inject(METADATA) private metadata: Metadata,
Expand Down
152 changes: 152 additions & 0 deletions src/app/about/description/component-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { DescriptionComponent } from './description.component';
import { ExtendedDescriptionTreeNode } from './extended-description-tree-node';

export class ComponentTreeNode {
public readonly children: ReadonlyArray<ComponentTreeNode>
public readonly parent: ComponentTreeNode | null
public readonly tree: ComponentTree
private _index?: number | null

private constructor(
public component: DescriptionComponent,
children: ReadonlyArray<DescriptionComponent>,
parent?: ComponentTreeNode,
) {
this.parent = parent || null
this.tree = parent?.tree || new ComponentTree(this)
this.children = children
.map((component) =>
new ComponentTreeNode(
component,
component.children.toArray() as unknown as ReadonlyArray<DescriptionComponent>,
this,
),
)
}

public static fromRootComponent(component: DescriptionComponent) {
if(component.parent) {
throw new Error('Just a root component can create a tree node')
}
return new ComponentTreeNode(
component,
component.children.toArray() as unknown as ReadonlyArray<DescriptionComponent>,
)
}

public get dataNode(): ExtendedDescriptionTreeNode {
return this.component.node
}

public findNodeByComponent(component: DescriptionComponent): ComponentTreeNode | undefined {
return this.children.find((node) => node.component === component)
}

public nextFocusable(): DescriptionComponent | undefined {
if (this.dataNode.isBranch && this.component.isExpanded) {
return this.children[0].component
}

if (this.nextSibling) {
return this.nextSibling?.component
}

return this.nextNode?.component
}

private get nextSibling(): ComponentTreeNode | undefined {
if (this.index === null || this.index === this.parent!.children.length - 1) {
return
}

return this.parent!.children[this.index + 1]
}

private get index(): number | null {
if (this._index === undefined) {
this._index = this._findIndex()
}

return this._index
}

private _findIndex(): number | null {
if (!this.parent) {
return null
}

const index = this.parent.children
.findIndex((node) => node == this)
if (index == -1) {
throw new Error("Can't find self as parent's child")
}
return index
}

private get nextNode(): ComponentTreeNode | undefined {
return this.parent?.nextSibling || this.parent?.nextNode
}

public previousFocusable(): DescriptionComponent | undefined {
if (this.previousSibling?.dataNode.isBranch && this.previousSibling.component.isExpanded) {
return this.lastFocusableChild(this.previousSibling)?.component
}

return this.previousSibling?.component || this.parent?.component
}

private get previousSibling(): ComponentTreeNode | undefined {
if (this.index === null || this.index === 0) {
return
}

return this.parent!.children[this.index - 1]
}

public lastFocusableChild(node: ComponentTreeNode): ComponentTreeNode | undefined {
if (!node.dataNode.isBranch || !node.component.isExpanded) {
return node
}
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i]
const lastChildFocusable = this.lastFocusableChild(child)
if (lastChildFocusable) {
return this.lastFocusableChild(child)
}
}
return
}
}

export class ComponentTree {
constructor(
public root: ComponentTreeNode,
) {
}
private _firstFocusable!: DescriptionComponent;

public get firstFocusable(): DescriptionComponent | undefined {
if (!this._firstFocusable) {
const firstFocusable = this._getFirstFocusableComponent(this.root)
if (!firstFocusable) {
throw new Error('No focusable first component found')
}
this._firstFocusable = firstFocusable
}
return this._firstFocusable
}

private _getFirstFocusableComponent(node: ComponentTreeNode): DescriptionComponent | undefined {
const firstFocusableDataNode = this.root.dataNode.tree.firstFocusable
if(node.dataNode === firstFocusableDataNode) {
return node.component
}
return node.children
.find((child) => child.dataNode == firstFocusableDataNode)
?.component
}

public get lastFocusable(): DescriptionComponent | undefined {
return this.root.lastFocusableChild(this.root)?.component
}
}
99 changes: 83 additions & 16 deletions src/app/about/description/description.component.html
Original file line number Diff line number Diff line change
@@ -1,17 +1,84 @@
<ng-container [ngTemplateOutlet]="linesTemplate"
[ngTemplateOutletContext]="{lines:metadata.descriptionLines}">
</ng-container>
<ng-template #linesTemplate let-lines='lines'>
<ul>
<li *ngFor="let line of lines">
<div class="line">
<span class="symbol material-symbols-outlined">{{ line.symbol }}</span>
<span class="content" [innerHtml]="sanitizer.bypassSecurityTrustHtml(line.html)"></span>
</div>
<ng-container *ngIf="line.lines"
[ngTemplateOutlet]="linesTemplate"
[ngTemplateOutletContext]="{lines:line.lines}">
</ng-container>
</li>
</ul>
<ng-template [ngIf]="node.data">
<div class="data" [class.actionable]="node.isBranch" (click)="onClick()"
(keydown.arrowLeft)="onKeydownArrowLeft()"
(keydown.arrowRight)="onKeydownArrowRight()"
(keydown.arrowDown)="onKeydownArrowDown()"
(keydown.arrowUp)="onKeydownArrowUp()"
(keydown.home)="onKeydownHome()"
(keydown.end)="onKeydownEnd()"
(keydown.*)="onKeydownAsterisk()"
(keydown.shift.*)="onKeydownShiftAsterisk()"
[attr.tabindex]="tabIndex"
>
<span class="symbol material-symbols-outlined">{{ node.data.symbol }}</span>
<span class="content" [innerHtml]="sanitizer.bypassSecurityTrustHtml(node.data.html)"></span>
<div class="collapsible-buttons" *ngIf="node.isBranch">
<button type="button" class="collapse material-symbols-outlined"
tabindex="-1">{{ MaterialSymbol.ExpandLess }}</button>
<button type="button" class="expand material-symbols-outlined"
tabindex="-1">{{ MaterialSymbol.ExpandMore }}</button>
</div>
</div>
</ng-template>
<ul [attr.role]="listRole" *ngIf="node.isBranch">
<!-- 👇 Linter suggests 'aria-selected' should be present for role 'treeitem', however...
There's an issue with ARIA specifications. Seems there's no agreement between practices and specifications on how
to implement a non-selectable tree view.
If you check the ARIA Authoring Practices Guide (APG), seems that "Tree View" is the proper role for a hierarchical
list:
"A tree view widget presents a hierarchical list"
Source: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
In the page regarding tree views, it is also added they can be expanded and collapsed:
"Any item in the hierarchy may have child items, and items that have children may be expanded or collapsed to
show or hide the children"
Source: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
And finally it also points out what to do when nodes in the tree aren't selectable:
"If the tree contains nodes that are not selectable, neither aria-selected nor aria-checked is present on those
nodes"
Source: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#:~:text=nodes%20that%20are%20not%20selectable
So we're fine if we don't use aria-selected / aria-checked like in our scenario where nodes don't select anything?
Well, seems the ARIA 1.2 specs don't allow that. Someone already reported that to the ARIA practices repository:
https://github.com/w3c/aria-practices/issues/667
Actually, specs seem to say that a tree view should have tree role. However, the tree role is defined as something
selectable in its definition:
"A widget that allows the user to select one or more items from a hierarchically organized collection"
Source: https://www.w3.org/TR/wai-aria-1.2/#tree
Which then makes sense that a tree item role requires the "aria-selected" attribute. So it inherits from "option"
role, which requires "aria-selected" attribute
Source: https://www.w3.org/TR/wai-aria-1.2/#treeitem
So to be specs compliant, 'aria-selected' should be there. Despite with a "false" value as there's nothing to
select. But if we do so, we tell a node can be selected, whilst we can't select anything here. And there's nothing
else on the spec to tell that this is a tree of info 🙃 So being not spec compliant here 😈
In fact, there have also other places where they use tree item role, but no aria-selected is there
<li id="apples" class="tree-parent" role="treeitem" tabindex="-1" aria-expanded="false">
https://www.w3.org/WAI/GL/wiki/Using_the_WAI-ARIA_aria-expanded_state_to_mark_expandable_and_collapsible_regions
Extra notes on accessibility:
- aria-hidden is not needed for collapsed items, given "display: none" hides it from accessibility tools already
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden
- Usage of tabindex in ARIA practices tree view example requires accessing the JS code (it's not in HTML inline)
https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-1a/
https://www.w3.org/WAI/content-assets/wai-aria-practices/patterns/treeview/examples/js/treeitem.js
-->
<!-- eslint-disable-next-line @angular-eslint/template/role-has-required-aria -->
<li *ngFor="let childNode of node.children" [attr.role]="listItemRole">
<app-description [node]="childNode" [parent]="this" #children>
</app-description>
</li>
</ul>
75 changes: 55 additions & 20 deletions src/app/about/description/description.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,15 @@
:host {
display: flex;
flex-direction: column;
padding: paddings.$m;
gap: margins.$m;

ul {
display: flex;
flex-direction: column;
gap: margins.$m;
}

ul > li {
display: flex;
flex-direction: column;
gap: margins.$m;
}

// Indentation for nested lists
ul ul {
margin-left: margins.$l;
}

.line {
.data {
display: flex;
align-items: center;
word-break: break-word;
font-size: 1.5rem;

// <a>'s come from content, and don't have the Angular's added ng_ attr
::ng-deep a {
text-decoration-line: underline;
text-decoration-thickness: 1px;
Expand All @@ -45,6 +29,56 @@
'GRAD' 200,
'opsz' 24
}

button {
background: transparent;
color: inherit;
border: none;
cursor: pointer;
vertical-align: -3px;
}

}

ul {
display: flex;
flex-direction: column;
gap: margins.$m;
}

// Indentation for nested lists
ul ul {
margin-left: margins.$l;
}


// Collapsible
.data.actionable {
cursor: pointer;
}

&[aria-expanded="true"] {
> .data button.expand {
display: none;
}

> .data button.collapse {
display: block;
}
}

&[aria-expanded="false"] {
> .data button.expand {
display: block;
}

> .data button.collapse {
display: none;
}

> ul {
display: none;
}
}

// Content-specific customizations
Expand All @@ -53,7 +87,8 @@
text-decoration-color: currentColor;
}

> ul > li > ul > li > .line {
// TODO: can be replaced when just those are collapsible
ul[role="tree"] > li > app-description > ul > li > app-description > .data {
font-weight: bold;
}
}
Loading

0 comments on commit b4bb892

Please sign in to comment.