Skip to content

Commit 4f9e026

Browse files
committed
feat(ngx-layout): WCAG/ARIA for drag-and-drop
1 parent 0e21932 commit 4f9e026

File tree

27 files changed

+911
-57
lines changed

27 files changed

+911
-57
lines changed

apps/layout-test/src/app/app.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { TourItemComponent } from '../tour/tour.component';
99
import { routes } from '../routes';
1010
import { TooltipComponent } from '../tooltip/tooltip.component';
1111
import { ConfirmModalComponent } from '../modal/confirm.component';
12-
import { provideNgxDisplayContentConfiguration } from '@ngx/layout';
12+
import { DragAndDropService } from '../services/drag-and-drop.service';
13+
import { provideNgxDisplayContentConfiguration, provideNgxDragAndDropService } from '@ngx/layout';
1314
import { provideNgxTourConfiguration } from '@ngx/tour';
1415
import { provideNgxModalConfiguration, provideNgxTooltipConfiguration } from '@ngx/inform';
1516

@@ -37,5 +38,6 @@ export const appConfig: ApplicationConfig = {
3738
panelClass: 'modal-panelelelelele',
3839
}),
3940
provideRouter(routes),
41+
provideNgxDragAndDropService(DragAndDropService),
4042
],
4143
};

apps/layout-test/src/pages/main/main.component.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ <h2>Editable</h2>
116116
[dropPredicate]="drop"
117117
rowGap="10px"
118118
columnGap="10px"
119+
itemLabel="Blok"
120+
rowLabel="rij"
119121
>
120122
<ngx-configurable-layout-item key="a">
121123
<div class="layout-items large">Form key a</div>
@@ -125,7 +127,7 @@ <h2>Editable</h2>
125127
<div class="layout-items">Form key b</div>
126128
</ngx-configurable-layout-item>
127129

128-
<ngx-configurable-layout-item key="1">
130+
<ngx-configurable-layout-item key="1" label="Custom blok">
129131
<div class="layout-items">
130132
Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptatum veritatis laudantium
131133
ut officia omnis, impedit eligendi, ea molestiae magnam odit et animi quod illo eius.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Injectable } from '@angular/core';
2+
import { Observable } from 'rxjs';
3+
import { NgxAccessibleDragAndDropAbstractService } from '@ngx/layout';
4+
5+
@Injectable({ providedIn: 'root' })
6+
export class DragAndDropService extends NgxAccessibleDragAndDropAbstractService {
7+
get currentLanguage(): string | Observable<string> {
8+
return 'nl';
9+
}
10+
}

libs/layout/README.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,43 @@ For more information about the build process, authors, contributions and issues,
2323

2424
`ngx-layout` is a package to help facilitate common layout use-cases.
2525

26-
Currently the package provides a `configurable layout` component which can be used to render components in a grid based on provided templates. This approach is ideal for use-cases such as a custom configurable dashboard.
27-
26+
Currently the package provides a `configurable layout` component which can be used to render components in a grid based on provided templates. This approach is ideal for use-cases such as a custom configurable dashboard. We also provide a fully accessible `accordion` component and an accessible `displayContent` approach to handle loading, error and offline flows.
2827

2928

3029
## Implementation
3130

31+
### Accessibility
32+
33+
With all the packages of Studio Hyperdrive we aim to provide components and implementations that are WCAG/WAI-ARIA compliant. This means that rather than making this optional to the implementation, we enforce it throughout the packages.
34+
35+
Where custom input is needed to make the implementation accessible, a `Accessibility` chapter can be found for each implementation.
36+
37+
#### Drag and drop
38+
Drag and drop is a common and well known pattern for end users, but often ends up being inaccessible for users that prefer or need to use a keyboard for interacting with the interface. On top of that, for visually impaired users, it becomes difficult to understand how to use this pattern.
39+
40+
Within this package we use `Angular CDK Drag and Drop`, but further enhance to make it accessible for keyboard users and users using screen readers. We already provide several measures for this functionality, but further input from the developer is required.
41+
42+
##### Concept
43+
44+
To make the drag and drop pattern accessible for keyboard users, we allow the items in the drag and drop container to be moved using keyboard interactions. By tabbing to the item and pressing `Enter` or `Space`, we can select an element and then move it using the `Arrow` keys. Once the item is in the correct place, we can deselect the element by pressing the `Enter` or `Space` key again.
45+
46+
For users with assistive technologies, such as screenreaders, we provide a a live region that will announce each change in the drag and drop container. This will announce select events, deselect events and move events. `ngx-layout` provides a set of default messages for a select amount of languages, but offers the ability to overwrite these with your own messages when needed.
47+
48+
##### Implementation
49+
50+
In order to make the drag and drop accessible for every user, you need to provide an implementation of the `NgxAccessibleDragAndDropAbstractService`. This service requires you to provide the current language of your application by implementing the `currentLanguage` method. This can be either a string or an Observable string.
51+
52+
If you wish to overwrite the default message record with your own, you can do that by providing the `customMessages` property. This is however optional, if not provided, the default language options will be provided.
53+
54+
You can provide your service in the following manner:
55+
56+
```ts
57+
providers: [
58+
provideNgxDragAndDropService(DragAndDropService),
59+
]
60+
```
61+
##### Setup
62+
3263
### Components
3364

3465
#### Accordion
@@ -95,6 +126,16 @@ By using content projection, we render our components inside of a `ngx-configura
95126

96127
This means that the order of rendering is now no longer depended on how you provide the components in the template, but by the two dimensional array provided to the `ngx-configurable-layout` component. This significantly streamlines the process and allows you to easily refactor existing flows. In the chapters below we'll explain how to provide the two dimensional array to the component.
97128

129+
In order to provide an accessible experience for end-users, the earlier mentioned `NgxDragAndDropService` needs to be provided.
130+
131+
##### Accessibility
132+
133+
In order to further customize the messages for end users with assistive technologies, we can pass several configuration items to the `ngx-configurable-layout` and `ngx-configurable-layout-item`.
134+
135+
By passing an `itemLabel` and a `rowLabel` we can define specific names for the rows and the items within the rows of the `ngx-configurable-layout`. By default, these are `item` and `list`; but you can change these to your own preference.
136+
137+
The `ngx-configurable-layout-item` also has an optional `label` property which can be used to overwrite both the default and the layout defined label for the item.
138+
98139
##### Static
99140

100141
Earlier we mentioned that the layout is build up using a provided two dimensional array. Depending on whether you want this layout to be `static` or `editable`, we provide the array in a different fashion.

libs/layout/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
"aria",
1717
"display-content",
1818
"loading",
19-
"error"
19+
"error",
20+
"drag and drop",
21+
"accessible drag and drop",
22+
"drag-and-drop",
23+
"accessible drag-and-drop"
2024
],
2125
"homepage": "https://github.com/studiohyperdrive/ngx-tools/tree/master/libs/layout",
2226
"license": "MIT",
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { filter, map, Observable, of, take, tap } from 'rxjs';
2+
import { inject } from '@angular/core';
3+
4+
import { UUID } from 'angular2-uuid';
5+
6+
import { NgxLiveRegionService } from '../../services';
7+
import {
8+
NgxAccessibleDragAndDropMessage,
9+
NgxAccessibleDragAndDropMessageRecord,
10+
} from '../../types';
11+
import { NgxAccessibleDragAndDropMessageRecords } from '../../const';
12+
import { hideElement } from '../../utils';
13+
14+
/**
15+
* An abstract service that is used to make drag and drop components accessible for assistive technologies
16+
*/
17+
export abstract class NgxAccessibleDragAndDropAbstractService {
18+
/**
19+
* The live region service
20+
*/
21+
private readonly liveRegionService: NgxLiveRegionService = inject(NgxLiveRegionService);
22+
23+
/**
24+
* A method that passes the current language, can either be a string or an Observable
25+
*/
26+
abstract get currentLanguage(): string | Observable<string>;
27+
28+
/**
29+
* A custom set of messages used for the drag and drop events.
30+
*
31+
* Please check the readme for more information on what is necessary to make these messages accessible.
32+
*/
33+
public customMessages: Record<string, NgxAccessibleDragAndDropMessageRecord>;
34+
35+
/**
36+
* Sets a message to the live region
37+
*
38+
* @param message - The provided message
39+
*/
40+
public setMessage(message: NgxAccessibleDragAndDropMessage): Observable<void> {
41+
// Iben: Take the current language to fetch the message
42+
return (
43+
typeof this.currentLanguage === 'string'
44+
? of(this.currentLanguage)
45+
: this.currentLanguage
46+
).pipe(
47+
filter(Boolean),
48+
take(1),
49+
tap((currentLanguage) => {
50+
// Iben: Fetch the necessary data
51+
const { type, data } = message;
52+
53+
let result: string = this.messageRecord[currentLanguage][type];
54+
55+
// Iben: If no message was found, we early exit and throw an error
56+
if (!result) {
57+
console.error(
58+
'NgxAccessibleDragAndDropAbstractService: No message for the corresponding drag and drop event was found.'
59+
);
60+
61+
return;
62+
}
63+
64+
// Iben: Replace the necessary substrings
65+
if (type === 'selected' || type === 'deselected' || type === 'cancelled') {
66+
result = result.replace(
67+
'{{#item}}',
68+
data.itemLabel || `${this.messageRecord[currentLanguage].item} ${data.item}`
69+
);
70+
} else if (type === 'moved') {
71+
console.dir(result);
72+
result = result
73+
.replace(
74+
'{{#item}}',
75+
data.itemLabel ||
76+
`${this.messageRecord[currentLanguage].item} ${data.item}`
77+
)
78+
.replace(
79+
`{{#from}}`,
80+
data.fromLabel ||
81+
`${this.messageRecord[currentLanguage].container} ${data.from}`
82+
)
83+
.replace(
84+
`{{#to}}`,
85+
data.toLabel ||
86+
`${this.messageRecord[currentLanguage].container} ${data.to}`
87+
);
88+
} else if (type === 'reordered') {
89+
result = result
90+
.replace(
91+
'{{#item}}',
92+
data.itemLabel ||
93+
`${this.messageRecord[currentLanguage].item} ${data.item}`
94+
)
95+
.replace(`{{#from}}`, data.from)
96+
.replace(`{{#to}}`, data.to);
97+
}
98+
99+
// Iben: Update the message in the live region
100+
this.liveRegionService.setMessage(result);
101+
}),
102+
map(() => null)
103+
);
104+
}
105+
106+
/**
107+
* Adds a description to the drag and drop host explaining how the drag and drop functions
108+
*
109+
* @param parent - The drag and drop host
110+
* @param description - An optional description used to overwrite the default description
111+
*/
112+
public setDragAndDropDescription(parent: HTMLElement, description?: string): Observable<void> {
113+
// Iben: Create the description element and its id
114+
const element: HTMLParagraphElement = document.createElement('p');
115+
const id: string = UUID.UUID();
116+
117+
// Iben: Take the current language to fetch the message
118+
return (
119+
typeof this.currentLanguage === 'string'
120+
? of(this.currentLanguage)
121+
: this.currentLanguage
122+
).pipe(
123+
tap((language: string) => {
124+
// Iben: Get the description text
125+
const text = description || this.messageRecord[language].description;
126+
127+
// Iben: If no description was found, we early exit and throw an error
128+
if (!text) {
129+
console.error(
130+
'NgxAccessibleDragAndDropAbstractService: No description for the drag and drop container was found.'
131+
);
132+
133+
return;
134+
}
135+
136+
// Iben: Set the description and id of the element
137+
element.innerText = text;
138+
element.setAttribute('id', id);
139+
140+
// Iben: Attach the element to the parent and update the aria id
141+
parent.appendChild(element);
142+
parent.setAttribute('aria-describedby', id);
143+
144+
// Iben: Hide element
145+
hideElement(element);
146+
}),
147+
map(() => null)
148+
);
149+
}
150+
151+
/**
152+
* Returns the custom message record or the default when no custom record was provided
153+
*/
154+
private get messageRecord(): Record<string, NgxAccessibleDragAndDropMessageRecord> {
155+
return this.customMessages || NgxAccessibleDragAndDropMessageRecords;
156+
}
157+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './display-content/display-content.component';
2+
export * from './drag-and-drop/drag-and-drop.service';

libs/layout/src/lib/components/configurable-layout-item/configurable-layout-item.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export class NgxConfigurableLayoutItemComponent {
1818
*/
1919
@Input({ required: true }) public key: string;
2020

21+
/**
22+
* An optional label for the layout item used for WCAG purposes.
23+
*/
24+
@Input() public label: string;
25+
2126
/**
2227
* The template reference of the;
2328
*/

0 commit comments

Comments
 (0)