From 5373e7ef27f0527cb5fb3b2656ba46ce25434ccd Mon Sep 17 00:00:00 2001 From: Iben Van de Veire Date: Wed, 23 Oct 2024 09:38:36 +0200 Subject: [PATCH] feat(ngx-layout): WCAG/ARIA for drag-and-drop --- apps/layout-test/src/app/app.config.ts | 4 +- .../src/pages/main/main.component.html | 4 +- .../src/services/drag-and-drop.service.ts | 10 + libs/layout/README.md | 47 +++- libs/layout/package.json | 6 +- .../drag-and-drop/drag-and-drop.service.ts | 165 +++++++++++ libs/layout/src/lib/abstracts/index.ts | 1 + .../configurable-layout-item.component.ts | 5 + .../configurable-layout.component.html | 108 ++++---- .../configurable-layout.component.ts | 61 ++++- .../drag-and-drop-message.const.ts | 107 ++++++++ libs/layout/src/lib/const/index.ts | 1 + .../drag-and-drop-container.directive.ts | 22 ++ .../drag-and-drop-host.directive.ts | 62 +++++ .../drag-and-drop-item.directive.ts | 259 ++++++++++++++++++ .../drag-and-drop/has-focus.directive.ts | 59 ++++ .../src/lib/directives/drag-and-drop/index.ts | 12 + libs/layout/src/lib/directives/index.ts | 1 + .../drag-and-drop/drag-and-drop.provider.ts | 16 ++ libs/layout/src/lib/providers/index.ts | 1 + libs/layout/src/lib/services/index.ts | 1 + .../live-region/live-region.service.ts | 74 +++++ .../src/lib/types/drag-and-drop.types.ts | 52 ++++ libs/layout/src/lib/types/index.ts | 1 + .../utils/hide-element/hide-element.util.ts | 12 + libs/layout/src/lib/utils/index.ts | 1 + libs/table/README.md | 2 +- 27 files changed, 1037 insertions(+), 57 deletions(-) create mode 100644 apps/layout-test/src/services/drag-and-drop.service.ts create mode 100644 libs/layout/src/lib/abstracts/drag-and-drop/drag-and-drop.service.ts create mode 100644 libs/layout/src/lib/const/drag-and-drop/drag-and-drop-message.const.ts create mode 100644 libs/layout/src/lib/const/index.ts create mode 100644 libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-container.directive.ts create mode 100644 libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-host.directive.ts create mode 100644 libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-item.directive.ts create mode 100644 libs/layout/src/lib/directives/drag-and-drop/has-focus.directive.ts create mode 100644 libs/layout/src/lib/directives/drag-and-drop/index.ts create mode 100644 libs/layout/src/lib/providers/drag-and-drop/drag-and-drop.provider.ts create mode 100644 libs/layout/src/lib/services/live-region/live-region.service.ts create mode 100644 libs/layout/src/lib/types/drag-and-drop.types.ts create mode 100644 libs/layout/src/lib/utils/hide-element/hide-element.util.ts create mode 100644 libs/layout/src/lib/utils/index.ts diff --git a/apps/layout-test/src/app/app.config.ts b/apps/layout-test/src/app/app.config.ts index a0a53b49..a93b2cfb 100644 --- a/apps/layout-test/src/app/app.config.ts +++ b/apps/layout-test/src/app/app.config.ts @@ -9,7 +9,8 @@ import { TourItemComponent } from '../tour/tour.component'; import { routes } from '../routes'; import { TooltipComponent } from '../tooltip/tooltip.component'; import { ConfirmModalComponent } from '../modal/confirm.component'; -import { provideNgxDisplayContentConfiguration } from '@ngx/layout'; +import { DragAndDropService } from '../services/drag-and-drop.service'; +import { provideNgxDisplayContentConfiguration, provideNgxDragAndDropService } from '@ngx/layout'; import { provideNgxTourConfiguration } from '@ngx/tour'; import { provideNgxModalConfiguration, provideNgxTooltipConfiguration } from '@ngx/inform'; @@ -37,5 +38,6 @@ export const appConfig: ApplicationConfig = { panelClass: 'modal-panelelelelele', }), provideRouter(routes), + provideNgxDragAndDropService(DragAndDropService), ], }; diff --git a/apps/layout-test/src/pages/main/main.component.html b/apps/layout-test/src/pages/main/main.component.html index 20c514e9..0dbc0785 100644 --- a/apps/layout-test/src/pages/main/main.component.html +++ b/apps/layout-test/src/pages/main/main.component.html @@ -116,6 +116,8 @@

Editable

[dropPredicate]="drop" rowGap="10px" columnGap="10px" + itemLabel="Blok" + rowLabel="rij" >
Form key a
@@ -125,7 +127,7 @@

Editable

Form key b
- +
Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptatum veritatis laudantium ut officia omnis, impedit eligendi, ea molestiae magnam odit et animi quod illo eius. diff --git a/apps/layout-test/src/services/drag-and-drop.service.ts b/apps/layout-test/src/services/drag-and-drop.service.ts new file mode 100644 index 00000000..38b19349 --- /dev/null +++ b/apps/layout-test/src/services/drag-and-drop.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { NgxAccessibleDragAndDropAbstractService } from '@ngx/layout'; + +@Injectable({ providedIn: 'root' }) +export class DragAndDropService extends NgxAccessibleDragAndDropAbstractService { + get currentLanguage(): string | Observable { + return 'nl'; + } +} diff --git a/libs/layout/README.md b/libs/layout/README.md index 84a4f960..db5d289a 100644 --- a/libs/layout/README.md +++ b/libs/layout/README.md @@ -23,12 +23,43 @@ For more information about the build process, authors, contributions and issues, `ngx-layout` is a package to help facilitate common layout use-cases. -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. - +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. ## Implementation +### Accessibility + +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. + +Where custom input is needed to make the implementation accessible, a `Accessibility` chapter can be found for each implementation. + +#### Drag and drop +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. + +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. + +##### Concept + +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. + +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. + +##### Implementation + +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. + +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. + +You can provide your service in the following manner: + +```ts +providers: [ + provideNgxDragAndDropService(DragAndDropService), +] +``` +##### Setup + ### Components #### Accordion @@ -95,6 +126,18 @@ By using content projection, we render our components inside of a `ngx-configura 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. +In order to provide an accessible experience for end-users, the earlier mentioned `NgxDragAndDropService` needs to be provided. + +##### Accessibility + +Currently, default texts have been provided for the following languages: Dutch (`nl`), English (`en`), French (`fr`), German (`de`), Spanish (`es`),Portuguese (`pt`), Turkish (`tr`), and Kurdish (`ku`). + +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`. + +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. + +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. + ##### Static 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. diff --git a/libs/layout/package.json b/libs/layout/package.json index d8d020f0..4a8f7300 100644 --- a/libs/layout/package.json +++ b/libs/layout/package.json @@ -16,7 +16,11 @@ "aria", "display-content", "loading", - "error" + "error", + "drag and drop", + "accessible drag and drop", + "drag-and-drop", + "accessible drag-and-drop" ], "homepage": "https://github.com/studiohyperdrive/ngx-tools/tree/master/libs/layout", "license": "MIT", diff --git a/libs/layout/src/lib/abstracts/drag-and-drop/drag-and-drop.service.ts b/libs/layout/src/lib/abstracts/drag-and-drop/drag-and-drop.service.ts new file mode 100644 index 00000000..ad290a44 --- /dev/null +++ b/libs/layout/src/lib/abstracts/drag-and-drop/drag-and-drop.service.ts @@ -0,0 +1,165 @@ +import { filter, map, Observable, of, take, tap } from 'rxjs'; +import { inject } from '@angular/core'; + +import { UUID } from 'angular2-uuid'; + +import { NgxLiveRegionService } from '../../services'; +import { + NgxAccessibleDragAndDropMessage, + NgxAccessibleDragAndDropMessageRecord, +} from '../../types'; +import { NgxAccessibleDragAndDropMessageRecords } from '../../const'; +import { hideElement } from '../../utils'; + +/** + * An abstract service that is used to make drag and drop components accessible for assistive technologies + */ +export abstract class NgxAccessibleDragAndDropAbstractService { + /** + * The live region service + */ + private readonly liveRegionService: NgxLiveRegionService = inject(NgxLiveRegionService); + + /** + * A method that passes the current language, can either be a string or an Observable + */ + abstract get currentLanguage(): string | Observable; + + /** + * A custom set of messages used for the drag and drop events. + * + * Please check the readme for more information on what is necessary to make these messages accessible. + */ + public customMessages: Record; + + /** + * Sets a message to the live region + * + * @param message - The provided message + */ + public setMessage(message: NgxAccessibleDragAndDropMessage): Observable { + // Iben: If no language was set, we early exit + if (!this.currentLanguage) { + console.error( + 'NgxAccessibleDragAndDropAbstractService: No language was provided, so no message could be set.' + ); + + return of(); + } + + // Iben: Take the current language to fetch the message + return ( + typeof this.currentLanguage === 'string' + ? of(this.currentLanguage) + : this.currentLanguage + ).pipe( + filter(Boolean), + take(1), + tap((currentLanguage) => { + // Iben: Fetch the necessary data + const { type, data } = message; + + let result: string = this.messageRecord[currentLanguage][type]; + + // Iben: If no message was found, we early exit and throw an error + if (!result) { + console.error( + 'NgxAccessibleDragAndDropAbstractService: No message for the corresponding drag and drop event was found.' + ); + + return; + } + + // Iben: Replace the necessary substrings + if (type === 'selected' || type === 'deselected' || type === 'cancelled') { + result = result.replace( + '{{#item}}', + data.itemLabel || `${this.messageRecord[currentLanguage].item} ${data.item}` + ); + } else if (type === 'moved') { + result = result + .replace( + '{{#item}}', + data.itemLabel || + `${this.messageRecord[currentLanguage].item} ${data.item}` + ) + .replace( + `{{#from}}`, + data.fromLabel || + `${this.messageRecord[currentLanguage].container} ${data.from}` + ) + .replace( + `{{#to}}`, + data.toLabel || + `${this.messageRecord[currentLanguage].container} ${data.to}` + ); + } else if (type === 'reordered') { + result = result + .replace( + '{{#item}}', + data.itemLabel || + `${this.messageRecord[currentLanguage].item} ${data.item}` + ) + .replace(`{{#from}}`, data.from) + .replace(`{{#to}}`, data.to); + } + + // Iben: Update the message in the live region + this.liveRegionService.setMessage(result); + }), + map(() => null) + ); + } + + /** + * Adds a description to the drag and drop host explaining how the drag and drop functions + * + * @param parent - The drag and drop host + * @param description - An optional description used to overwrite the default description + */ + public setDragAndDropDescription(parent: HTMLElement, description?: string): Observable { + // Iben: Create the description element and its id + const element: HTMLParagraphElement = document.createElement('p'); + const id: string = UUID.UUID(); + + // Iben: Take the current language to fetch the message + return ( + typeof this.currentLanguage === 'string' + ? of(this.currentLanguage) + : this.currentLanguage + ).pipe( + tap((language: string) => { + // Iben: Get the description text + const text = description || this.messageRecord[language].description; + + // Iben: If no description was found, we early exit and throw an error + if (!text) { + console.error( + 'NgxAccessibleDragAndDropAbstractService: No description for the drag and drop container was found.' + ); + + return; + } + + // Iben: Set the description and id of the element + element.innerText = text; + element.setAttribute('id', id); + + // Iben: Attach the element to the parent and update the aria id + parent.appendChild(element); + parent.setAttribute('aria-describedby', id); + + // Iben: Hide element + hideElement(element); + }), + map(() => null) + ); + } + + /** + * Returns the custom message record or the default when no custom record was provided + */ + private get messageRecord(): Record { + return this.customMessages || NgxAccessibleDragAndDropMessageRecords; + } +} diff --git a/libs/layout/src/lib/abstracts/index.ts b/libs/layout/src/lib/abstracts/index.ts index bc32a153..f2d4e8a8 100644 --- a/libs/layout/src/lib/abstracts/index.ts +++ b/libs/layout/src/lib/abstracts/index.ts @@ -1 +1,2 @@ export * from './display-content/display-content.component'; +export * from './drag-and-drop/drag-and-drop.service'; diff --git a/libs/layout/src/lib/components/configurable-layout-item/configurable-layout-item.component.ts b/libs/layout/src/lib/components/configurable-layout-item/configurable-layout-item.component.ts index 6c8176ff..1d83dede 100644 --- a/libs/layout/src/lib/components/configurable-layout-item/configurable-layout-item.component.ts +++ b/libs/layout/src/lib/components/configurable-layout-item/configurable-layout-item.component.ts @@ -18,6 +18,11 @@ export class NgxConfigurableLayoutItemComponent { */ @Input({ required: true }) public key: string; + /** + * An optional label for the layout item used for WCAG purposes. + */ + @Input() public label: string; + /** * The template reference of the; */ diff --git a/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.html b/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.html index 5a6455ed..d021fac1 100644 --- a/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.html +++ b/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.html @@ -1,55 +1,63 @@
- @for (row of form.value; track row; let index = $index) { -
    - @for (item of row; track item) { - @if (itemTemplateRecord()[item.key]) { - @if (showInactive || item.isActive) { -
  • - - @if (!form.disabled && showInactive && !item.disabled) { - - - } -
    -
  • - } - } - } -
- } + cdkDropListGroup + ngxAccessibleDragAndDropHost + class="ngx-layout-grid" + [ngxAccessibleDragAndDropHostDescription]="description" + [class.ngx-layout-equal-size]="itemSize === 'equal'" + [style.row-gap]="rowGap" + [class.ngx-layout-grid-inactive-shown]="showInactive" +> + @for (row of form.value; track row; let index = $index) { +
    + @for (item of row; track item; let index = $index) { @if (itemTemplateRecord()[item.key]) { + @if (showInactive || item.isActive) { +
  • + + @if (!form.disabled && showInactive && !item.disabled) { + + + } +
    +
  • + } } } +
+ }
- + diff --git a/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.ts b/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.ts index edff68cc..27923a0e 100644 --- a/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.ts +++ b/libs/layout/src/lib/components/configurable-layout/configurable-layout.component.ts @@ -45,6 +45,7 @@ import { } from 'rxjs'; import { NgxConfigurableLayoutItemComponent } from '../configurable-layout-item/configurable-layout-item.component'; import { + NgxAccessibleDragAndDropMoveEvent, NgxConfigurableLayoutGrid, NgxConfigurableLayoutItemDropEvent, NgxConfigurableLayoutItemEntity, @@ -52,6 +53,7 @@ import { NgxConfigurableLayoutType, } from '../../types'; import { NgxConfigurableLayoutItemSizePipe } from '../../pipes'; +import { NgxAccessibleDragAndDrop } from '../../directives'; /** * This component acts essentially as a layout wrapper. In combination with the @@ -78,6 +80,7 @@ import { NgxConfigurableLayoutItemSizePipe } from '../../pipes'; NgxConfigurableLayoutItemSizePipe, ReactiveFormsModule, CommonModule, + NgxAccessibleDragAndDrop, ], providers: [ { @@ -123,6 +126,11 @@ export class NgxConfigurableLayoutComponent */ public itemTemplateRecord: WritableSignal>> = signal({}); + /** + * A record of the label with the unique item `key` and its `label`. + */ + public itemLabelRecord: WritableSignal> = signal({}); + /** * Whether the layout is static or editable. * @@ -188,6 +196,21 @@ export class NgxConfigurableLayoutComponent */ @Input() public dropPredicate: (event: NgxConfigurableLayoutItemDropEvent) => boolean; + /** + * An optional label for the layout item used for WCAG purposes. + */ + @Input() public itemLabel: string; + + /** + * An optional label for the layout item used for WCAG purposes. + */ + @Input() public rowLabel: string; + + /** + * An optional description explaining how the drag and drop works used for WCAG purposes. + */ + @Input() public description: string; + /** * An optional column gap we can provide to create a gap between the columns of the layout. * @@ -341,6 +364,37 @@ export class NgxConfigurableLayoutComponent this.form.setValue(formData); } + /** + * Moves an element based on its provided event + * + * @param event - The provided move event + */ + public move(event: NgxAccessibleDragAndDropMoveEvent): void { + // Iben: Get the required data + const formData = [...this.form.value]; + const startArray = formData[event.previousContainer]; + const endArray = formData[event.newContainer]; + + // Iben: Early exit if the provided containers don't exist + if (isNaN(event.previousContainer) || isNaN(event.newContainer)) { + return; + } + + // Iben: If the drag and drop is within the same row, we move + if (event.previousContainer === event.newContainer) { + moveItemInArray(endArray, event.previousIndex, event.newIndex); + } else { + // Iben: If the drag and drop is over multiple rows, we transfer + transferArrayItem(startArray, endArray, event.previousIndex, event.newIndex); + formData[event.previousContainer] = startArray; + } + + formData[event.newContainer] = endArray; + + // Iben: Update the form value + this.form.setValue(formData); + } + /** * The predicate we run before sorting * @@ -401,12 +455,17 @@ export class NgxConfigurableLayoutComponent private handleItemTemplates(): void { // Wouter: Clear the current item template record this.itemTemplateRecord.set({}); + this.itemLabelRecord.set({}); Array.from(this.configurableItemTemplates).forEach((itemTemplate) => { - const { key, template } = itemTemplate; + const { key, template, label } = itemTemplate; // Wouter: Update the item template record with the unique column key and its template ref this.itemTemplateRecord.update((value) => ({ ...value, [key]: template })); + this.itemLabelRecord.update((value) => ({ + ...value, + [key]: label || key, + })); }); } } diff --git a/libs/layout/src/lib/const/drag-and-drop/drag-and-drop-message.const.ts b/libs/layout/src/lib/const/drag-and-drop/drag-and-drop-message.const.ts new file mode 100644 index 00000000..0373592a --- /dev/null +++ b/libs/layout/src/lib/const/drag-and-drop/drag-and-drop-message.const.ts @@ -0,0 +1,107 @@ +import { NgxAccessibleDragAndDropMessageRecord } from '../../types'; + +export const NgxAccessibleDragAndDropMessageRecords: Record< + string, + NgxAccessibleDragAndDropMessageRecord +> = { + nl: { + selected: '{{#item}} geselecteerd.', + deselected: '{{#item}} gedeselecteerd.', + reordered: '{{#item}} werd verplaatst van positie {{#from}} naar {{#to}}.', + moved: '{{#item}} werd verplaatst van {{#from}} naar {{#to}}.', + cancelled: 'Het verplaatsen van {{#item}} werd geannuleerd.', + item: 'item', + container: 'lijst', + description: + 'Om items in dit onderdeel te verplaatsen, navigeer naar een item via de Tab toets. Door de Enter of Space toets in te drukken selecteer je een item. Met de pijltjes toetsen verplaats je het item.', + }, + en: { + selected: '{{#item}} selected.', + deselected: '{{#item}} deselected.', + reordered: '{{#item}} was moved from position {{#from}} to {{#to}}.', + moved: '{{#item}} was moved from {{#from}} to {{#to}}.', + cancelled: 'Moving {{#item}} was cancelled.', + item: 'item', + container: 'list', + description: + 'To move items in this container, navigate to the item using the Tab key. By pressing Enter or Space you can select the item, which you can then move by using the Arrow keys.', + }, + fr: { + selected: '{{#item}} sélectionné.', + deselected: '{{#item}} désélectionné.', + reordered: '{{#item}} a été déplacé de la position {{#from}} à {{#to}}.', + moved: '{{#item}} a été déplacé de {{#from}} à {{#to}}.', + cancelled: 'Le déplacement de {{#item}} a été annulé.', + item: 'article', + container: 'liste', + description: + "Pour déplacer des éléments dans cette section, naviguez vers un élément à l'aide de la touche Tab. Appuyez sur la touche Entrée ou Espace pour sélectionner un élément. Utilisez les touches fléchées pour déplacer l'élément.", + }, + es: { + selected: '{{#item}} elegido.', + deselected: '{{#item}} rechazado.', + reordered: '{{#item}} fue reorganizado de la posición {{#from}} a {{#to}}.', + moved: '{{#item}} fue trasladado de {{#from}} a {{#to}}.', + cancelled: 'El desplazamiento de {{#item}} estuvo anulado.', + item: 'objeto', + container: 'lista', + description: + 'Para trasladar los objetos en esta sección, diríjase al objeto con la tecla Tab. Al tocar el botón Enter o el botón de Espacio puede seleccionar el objeto. Con las flechas puede trasladar el objeto.', + }, + pt: { + selected: '{{#item}} selecionado.', + deselected: '{{#item}} desselecionado.', + reordered: '{{#item}} foi movido da posição {{#from}} para {{#to}}.', + moved: '{{#item}} foi movido de {{#from}} para {{#to}}.', + cancelled: 'A movimentação de {{#item}} foi cancelada.', + item: 'item', + container: 'lista', + description: + 'Para mover itens nesta seção, navegue até um item usando a tecla Tab. Pressione Enter ou Barra de Espaço para selecionar um item. Use as teclas de seta para mover o item.', + }, + tr: { + selected: '{{#item}} seçildi.', + deselected: '{{#item}} seçimi kaldırıldı.', + reordered: '{{#item}} {{#from}} konumundan {{#to}} konumuna taşındı.', + moved: '{{#item}} {{#from}} konumundan {{#to}} konumuna taşındı.', + cancelled: '{{#item}} taşınması iptal edildi.', + item: 'öğe', + container: 'liste', + description: + 'Bu bölümdeki öğeleri taşımak için Tab tuşunu kullanarak bir öğeye gidin. Bir öğeyi seçmek için Enter veya Boşluk tuşuna basın. Öğeyi taşımak için ok tuşlarını kullanın.', + }, + de: { + selected: '{{#item}} ausgewählt.', + deselected: '{{#item}} abgewählt.', + reordered: '{{#item}} wurde von Position {{#from}} nach Position {{#to}} verschoben.', + moved: '{{#item}} wurde von {{#from}} nach {{#to}} verschoben.', + cancelled: 'Das Verschieben von {{#item}} wurde abgebrochen.', + item: 'Eintrag', + container: 'Liste', + + description: + 'Um Einträge in diesem Teil zu verschieben, navigiere mit der Tab-Taste zu einem Eintrag. Mit Drücken der Eingabe- oder Leerzeichentaste wählst du einen Eintrag aus. Mit den Pfeiltasten verschiebst du den Eintrag.', + }, + ku: { + selected: '{{#item}} hate hilbijartin.', + deselected: '{{#item}} hate rakirin.', + reordered: '{{#item}} ji cîhê {{#from}} ve ji cîhê {{#to}} ve kişandin.', + moved: '{{#item}} hate veguhastin ji {{#from}} ve {{#to}}.', + cancelled: 'Kişandina {{#item}} hate betal kirin.', + item: 'hêman', + container: 'lista', + description: + 'Ji bo dikaribî hêmanên vê beşê bibî/bikşînî bibî devereka dî pêl tûşa TABê bike û here ser hêmanekî. Ji bo hêmanekî bineqînî pêl tûşa ENTER an jî tûşa SPACE yê bike. Ji bo hêmanekî bibî devereka dî tûşa OK bi kar bîne.', + }, + // TODO: Iben: Get description translation + // ru: { + // selected: '{{#item}} выбрано.', + // deselected: '{{#item}} снято выделение.', + // reordered: '{{#item}} перенесен с позиции {{#from}} на {{#to}}.', + // moved: '{{#item}} перенесен с {{#from}} на {{#to}}.', + // cancelled: 'перемещение {{#item}} был отменен.', + // item: 'элемент', + // container: 'список', + // description: 'TBD', + // }, +}; diff --git a/libs/layout/src/lib/const/index.ts b/libs/layout/src/lib/const/index.ts new file mode 100644 index 00000000..63ff211b --- /dev/null +++ b/libs/layout/src/lib/const/index.ts @@ -0,0 +1 @@ +export * from './drag-and-drop/drag-and-drop-message.const'; diff --git a/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-container.directive.ts b/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-container.directive.ts new file mode 100644 index 00000000..4a068814 --- /dev/null +++ b/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-container.directive.ts @@ -0,0 +1,22 @@ +import { Directive, Input } from '@angular/core'; + +/** + * A directive to handle accessible drag and drop flows. This directive is meant to be placed on the drag and drop container(s). + */ +@Directive({ + selector: '[ngxAccessibleDragAndDropContainer]', + exportAs: 'ngxAccessibleDragAndDropContainer', + standalone: true, +}) +export class NgxAccessibleDragAndDropContainerDirective { + /** + * The index of the container + */ + @Input({ required: true, alias: 'ngxAccessibleDragAndDropContainerIndex' }) + public index: number; + + /** + * An optional label used in the event messages + */ + @Input({ alias: 'ngxAccessibleDragAndDropContainerLabel' }) public label: string; +} diff --git a/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-host.directive.ts b/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-host.directive.ts new file mode 100644 index 00000000..a0ef9324 --- /dev/null +++ b/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-host.directive.ts @@ -0,0 +1,62 @@ +import { ContentChildren, Directive, ElementRef, Input, QueryList } from '@angular/core'; + +import { NgxAccessibleDragAndDropAbstractService } from '../../abstracts'; +import { NgxAccessibleDragAndDropItemDirective } from './drag-and-drop-item.directive'; +import { NgxAccessibleDragAndDropContainerDirective } from './drag-and-drop-container.directive'; + +/** + * A directive to handle accessible drag and drop flows. This directive is meant to be placed on the host of the drag and drop container(s). + */ +@Directive({ + selector: '[ngxAccessibleDragAndDropHost]', + exportAs: 'ngxAccessibleDragAndDropHost', + standalone: true, +}) +export class NgxAccessibleDragAndDropHostDirective { + /** + * A list of all the drag and drop items + */ + @ContentChildren(NgxAccessibleDragAndDropItemDirective, { descendants: true }) + public items: QueryList; + + /** + * A list of all the drag and drop containers + */ + @ContentChildren(NgxAccessibleDragAndDropContainerDirective, { descendants: true }) + public containers: QueryList; + + /** + * An optional description describing how the drag and drop works. + */ + @Input({ alias: 'ngxAccessibleDragAndDropHostDescription' }) public description: string; + + constructor( + private readonly dragAndDropService: NgxAccessibleDragAndDropAbstractService, + public readonly elementRef: ElementRef + ) {} + + /** + * Mark a specific drag and drop item as active + * + * @param id - The id of the drag and drop item + */ + public markAsActive(id: string): void { + this.items?.find((item) => item.itemId === id)?.markAsActive(); + } + + /** + * Returns the container based on the provided index + * + * @param index - The index of the container + */ + public getContainer(index: number): NgxAccessibleDragAndDropContainerDirective { + return this.containers.find((container) => container.index === index); + } + + public ngAfterViewInit(): void { + // Iben: Add the description tag + this.dragAndDropService + .setDragAndDropDescription(this.elementRef.nativeElement, this.description) + .subscribe(); + } +} diff --git a/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-item.directive.ts b/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-item.directive.ts new file mode 100644 index 00000000..7b274425 --- /dev/null +++ b/libs/layout/src/lib/directives/drag-and-drop/drag-and-drop-item.directive.ts @@ -0,0 +1,259 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + signal, + WritableSignal, +} from '@angular/core'; +import { CdkDropList } from '@angular/cdk/drag-drop'; + +import { NgxAccessibleDragAndDropAbstractService } from '../../abstracts'; +import { NgxAccessibleDragAndDropMoveEvent, NgxAccessibleDragAndDropMoveType } from '../../types'; +import { NgxHasFocusDragAndDropAbstractDirective } from './has-focus.directive'; +import { NgxAccessibleDragAndDropContainerDirective } from './drag-and-drop-container.directive'; +import { NgxAccessibleDragAndDropHostDirective } from './drag-and-drop-host.directive'; + +/** + * A directive to handle accessible drag and drop flows. This directive is meant to be placed on the item(s) of the drag and drop container(s). + */ +@Directive({ + selector: '[ngxAccessibleDragAndDropItem]', + exportAs: 'ngxAccessibleDragAndDropItem', + standalone: true, + host: { + '[attr.tabIndex]': 'tabIndex()', + }, +}) +export class NgxAccessibleDragAndDropItemDirective extends NgxHasFocusDragAndDropAbstractDirective { + /** + * The tab index of the item + */ + public tabIndex: WritableSignal = signal(0); + + /** + * The selected state of the item + */ + @HostBinding('attr.aria-selected') public isSelected: boolean = false; + + /** + * Handle the selected state when pressing enter + */ + @HostListener('keydown.Enter') public onEnter(): void { + this.handlePress(); + } + + /** + * Handle the selected state when pressing space + */ + @HostListener('keydown.Space') public onSpace(): void { + this.handlePress(); + } + + /** + * Handle the ArrowUp Press + */ + @HostListener('keydown.ArrowUp', ['$event']) public onArrowUp(event: KeyboardEvent): void { + this.moveItem('up', event); + } + + /** + * Handle the ArrowDown Press + */ + @HostListener('keydown.ArrowDown', ['$event']) public onArrowDown(event: KeyboardEvent): void { + this.moveItem('down', event); + } + + /** + * Handle the ArrowLeft Press + */ + @HostListener('keydown.ArrowLeft', ['$event']) public onArrowLeft(event: KeyboardEvent): void { + this.moveItem('left', event); + } + + /** + * Handle the ArrowRight Press + */ + @HostListener('keydown.ArrowRight', ['$event']) public onArrowRight( + event: KeyboardEvent + ): void { + this.moveItem('right', event); + } + + /** + * The index of the draggable item + */ + @Input({ required: true, alias: 'ngxAccessibleDragAndDropItemIndex' }) public itemIndex: number; + + /** + * An unique id of the draggable item + */ + @Input({ required: true, alias: 'ngxAccessibleDragAndDropItemId' }) public itemId: string; + + /** + * An optional label for the draggable item + */ + @Input({ alias: 'ngxAccessibleDragAndDropLabel' }) public label: string; + + /** + * Whether the draggable item is disabled + */ + @Input({ alias: 'ngxAccessibleDragAndDropDisabled' }) public set disabled(isDisabled: boolean) { + this.tabIndex.set(isDisabled ? -1 : 0); + } + + /** + * Emits when the item has been moved through keyboard input + */ + @Output() + public ngxAccessibleDragAndDropItemMove: EventEmitter = + new EventEmitter(); + + constructor( + private readonly dragAndDropService: NgxAccessibleDragAndDropAbstractService, + private readonly dropList: CdkDropList, + private readonly dropContainer: NgxAccessibleDragAndDropContainerDirective, + private readonly dropHost: NgxAccessibleDragAndDropHostDirective, + public readonly elementRef: ElementRef + ) { + super(elementRef); + } + + /** + * Marks the item as focussed and selected + */ + public markAsActive(): void { + this.focus(); + this.isSelected = true; + } + + /** + * Deselects the current item if it's selected + * + */ + public onBlur(): void { + // Iben: Early exit if the item is not selected + if (!this.isSelected) { + return; + } + + // Iben: Set the item as deselected + this.isSelected = false; + + // Iben: Announce the item as deselected + this.dragAndDropService + .setMessage({ + type: 'deselected', + data: { item: `${this.itemIndex}`, itemLabel: this.label || undefined }, + }) + .subscribe(); + } + + /** + * Handles the pressing of a button in the drag and drop host + */ + private handlePress(): void { + this.handleWhenFocussed(() => { + this.isSelected = !this.isSelected; + this.dragAndDropService + .setMessage({ + type: this.isSelected ? 'selected' : 'deselected', + data: { item: `${this.itemIndex}`, itemLabel: this.label || undefined }, + }) + .subscribe(); + }); + } + + /** + * Moves the item in the correct direction + * + * @param key - The pressed key + * @param event - The keyboard event + */ + private moveItem(key: 'up' | 'down' | 'left' | 'right', event: KeyboardEvent): void { + if (!this.disabled && this.hasFocus && this.isSelected) { + // Iben: Prevent the default action + event.preventDefault(); + event.stopPropagation(); + + // Iben: Set up the needed items + let newIndex: number; + let newContainer: number; + + const currentContainer = this.dropContainer.index; + const isHorizontal = this.dropList.orientation === 'horizontal'; + const isUpOrDown: boolean = key === 'up' || key === 'down'; + + // Iben: In this case we're changing the current container + if ((isUpOrDown && isHorizontal) || (!isUpOrDown && !isHorizontal)) { + newIndex = this.itemIndex; + newContainer = + key === 'up' || key === 'left' ? currentContainer - 1 : currentContainer + 1; + + this.handleItemMove(newIndex, newContainer, 'moved'); + } + + // Iben: In this case, we're changing the order of the items + if ((!isUpOrDown && isHorizontal) || (isUpOrDown && !isHorizontal)) { + newIndex = key === 'up' || key === 'left' ? this.itemIndex - 1 : this.itemIndex + 1; + newContainer = currentContainer; + this.handleItemMove(newIndex, newContainer, 'reordered'); + } + } + } + + /** + * Moves an item based on the provided container and index, and sends a message to the live region + * + * @private + * @param newIndex - The new index of the item + * @param newContainer - The container we wish to move the item to + * @param type - The type of movement we perform + */ + private handleItemMove( + newIndex: number, + newContainer: number, + type: NgxAccessibleDragAndDropMoveType + ): void { + // Iben: Check if the newContainer exits, if not early exit + const targetContainer = this.dropHost.getContainer(newContainer); + + if (!targetContainer) { + return; + } + + // Iben: Emit the move event + this.ngxAccessibleDragAndDropItemMove.emit({ + previousIndex: this.itemIndex, + newIndex, + previousContainer: this.dropContainer.index, + newContainer, + }); + + // Iben: Set the message in the live region + this.dragAndDropService + .setMessage({ + type: type, + data: { + item: this.itemId, + itemLabel: this.label || undefined, + from: + type === 'reordered' + ? `${this.itemIndex + 1}` + : `${this.dropContainer.index + 1}`, + to: type === 'reordered' ? `${newIndex + 1}` : `${newContainer + 1}`, + fromLabel: this.dropContainer.label || undefined, + toLabel: targetContainer.label || undefined, + }, + }) + .subscribe(); + + // Iben: Set the focus and select the new item with the same id, using a setTimeOut so the correct item is rendered first + setTimeout(() => { + this.dropHost.markAsActive(this.itemId); + }); + } +} diff --git a/libs/layout/src/lib/directives/drag-and-drop/has-focus.directive.ts b/libs/layout/src/lib/directives/drag-and-drop/has-focus.directive.ts new file mode 100644 index 00000000..7ea53370 --- /dev/null +++ b/libs/layout/src/lib/directives/drag-and-drop/has-focus.directive.ts @@ -0,0 +1,59 @@ +import { Directive, ElementRef, HostListener } from '@angular/core'; + +//TODO: Iben: Move this copy to a shared lib once we have figured out how to handle that. + +/** + * An abstract directive used as a base to handle focussed base actions + */ +@Directive({ + standalone: true, +}) +export abstract class NgxHasFocusDragAndDropAbstractDirective { + /** + * Whether the current element is focussed + */ + protected hasFocus: boolean = false; + + /** + * Set the hasFocus flag + */ + @HostListener('focus') public setFocus(): void { + this.hasFocus = true; + + if (this.onFocus) { + this.onFocus(); + } + } + + /** + * Remove the hasFocus flag + */ + @HostListener('blur') public removeFocus() { + this.hasFocus = false; + + if (this.onBlur) { + this.onBlur(); + } + } + + constructor(public readonly elementRef: ElementRef) {} + + public focus(): void { + this.elementRef.nativeElement.focus(); + } + + public onBlur?(): void; + + public onFocus?(): void; + + /** + * Execute an action when the element has focussed + * + * @param action - The provided action + */ + public handleWhenFocussed(action: () => void): void { + if (this.hasFocus) { + action(); + } + } +} diff --git a/libs/layout/src/lib/directives/drag-and-drop/index.ts b/libs/layout/src/lib/directives/drag-and-drop/index.ts new file mode 100644 index 00000000..19b77d9a --- /dev/null +++ b/libs/layout/src/lib/directives/drag-and-drop/index.ts @@ -0,0 +1,12 @@ +import { NgxAccessibleDragAndDropItemDirective } from './drag-and-drop-item.directive'; +import { NgxAccessibleDragAndDropContainerDirective } from './drag-and-drop-container.directive'; +import { NgxAccessibleDragAndDropHostDirective } from './drag-and-drop-host.directive'; + +/** + * All the needed directives for the accessible drag and drop implementation + */ +export const NgxAccessibleDragAndDrop = [ + NgxAccessibleDragAndDropItemDirective, + NgxAccessibleDragAndDropContainerDirective, + NgxAccessibleDragAndDropHostDirective, +] as const; diff --git a/libs/layout/src/lib/directives/index.ts b/libs/layout/src/lib/directives/index.ts index ae2781bd..442b8933 100644 --- a/libs/layout/src/lib/directives/index.ts +++ b/libs/layout/src/lib/directives/index.ts @@ -1 +1,2 @@ export * from './display-content/display-content.directive'; +export { NgxAccessibleDragAndDrop } from './drag-and-drop'; diff --git a/libs/layout/src/lib/providers/drag-and-drop/drag-and-drop.provider.ts b/libs/layout/src/lib/providers/drag-and-drop/drag-and-drop.provider.ts new file mode 100644 index 00000000..c1f0b199 --- /dev/null +++ b/libs/layout/src/lib/providers/drag-and-drop/drag-and-drop.provider.ts @@ -0,0 +1,16 @@ +import { Provider, Type } from '@angular/core'; +import { NgxAccessibleDragAndDropAbstractService } from '../../abstracts'; + +/** + * Provides a custom implementation of the NgxDragAndDropService + * + * @param service - The custom implementation for the NgxDragAndDropService + */ +export const provideNgxDragAndDropService = ( + service: Type +): Provider => { + return { + provide: NgxAccessibleDragAndDropAbstractService, + useClass: service, + }; +}; diff --git a/libs/layout/src/lib/providers/index.ts b/libs/layout/src/lib/providers/index.ts index a60227b0..f6e704f9 100644 --- a/libs/layout/src/lib/providers/index.ts +++ b/libs/layout/src/lib/providers/index.ts @@ -1 +1,2 @@ export * from './display-content/display-content.provider'; +export * from './drag-and-drop/drag-and-drop.provider'; diff --git a/libs/layout/src/lib/services/index.ts b/libs/layout/src/lib/services/index.ts index 83e04f76..cd3fc267 100644 --- a/libs/layout/src/lib/services/index.ts +++ b/libs/layout/src/lib/services/index.ts @@ -1 +1,2 @@ export * from './online-service/online.service'; +export * from './live-region/live-region.service'; diff --git a/libs/layout/src/lib/services/live-region/live-region.service.ts b/libs/layout/src/lib/services/live-region/live-region.service.ts new file mode 100644 index 00000000..77960c1b --- /dev/null +++ b/libs/layout/src/lib/services/live-region/live-region.service.ts @@ -0,0 +1,74 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { hideElement } from '../../utils'; + +/** + * A singleton service that adds a live region element to the DOM, to which WCAG/WAI-ARIA announcements can be send. + */ +@Injectable({ + providedIn: 'root', +}) +export class NgxLiveRegionService { + /** + * The live region element + */ + private region: HTMLElement; + + constructor( + @Inject(PLATFORM_ID) platformId: string, + @Inject(DOCUMENT) private document: Document + ) { + // Iben: Create a live region element + if (isPlatformBrowser(platformId)) { + this.createLiveRegion(); + } + } + + /** + * Return whether the live region is available + */ + public get hasLiveRegion(): boolean { + return Boolean(this.region); + } + + /** + * Set a message in the live region + * + * @param message - The provided message + */ + public setMessage(message: string): void { + // Iben: Display the message if the live region is present, if not, throw a warning + if (this.hasLiveRegion) { + this.region.innerText = message; + } else { + console.warn( + 'NgxLiveRegionService: An attempt was made to set a message to the live region when the live region was not yet created.' + ); + } + } + + /** + * Creates a live region where we can broadcast announcements intended for assistive technologies to + */ + private createLiveRegion(): void { + // Iben: Early exit if a live region already exists + if (this.hasLiveRegion) { + return; + } + + // Iben: Create initial element + this.region = this.document.createElement('div'); + + // Iben: Set the needed aria-attributes + this.region.setAttribute('aria-live', 'assertive'); + this.region.setAttribute('role', 'log'); + this.region.setAttribute('aria-relevant', 'additions'); + this.region.setAttribute('aria-atomic', 'true'); + + //Iben: Move the live region out of sight for without assistive technologies + hideElement(this.region); + + // Iben: Append the live region to the body + this.document.body.appendChild(this.region); + } +} diff --git a/libs/layout/src/lib/types/drag-and-drop.types.ts b/libs/layout/src/lib/types/drag-and-drop.types.ts new file mode 100644 index 00000000..9a9cf1e7 --- /dev/null +++ b/libs/layout/src/lib/types/drag-and-drop.types.ts @@ -0,0 +1,52 @@ +type NgxAccessibleDragAndDropFromToMessage = + | `${string}{{#item}}${string}{{#to}}${string}{{#from}}${string}` + | `${string}{{#item}}${string}{{#from}}${string}{{#to}}${string}` + | `${string}{{#to}}${string}{{#item}}${string}{{#from}}${string}` + | `${string}{{#to}}${string}{{#from}}${string}{{#item}}${string}` + | `${string}{{#from}}${string}{{#item}}${string}{{#to}}${string}` + | `${string}{{#from}}${string}{{#to}}${string}{{#item}}${string}`; + +export interface NgxAccessibleDragAndDropMessageRecord { + selected: `${string}{{#item}}${string}`; + deselected: `${string}{{#item}}${string}`; + reordered: NgxAccessibleDragAndDropFromToMessage; + moved: NgxAccessibleDragAndDropFromToMessage; + cancelled: `${string}{{#item}}${string}`; + item: string; + container: string; + description: string; +} + +export type NgxAccessibleDragAndDropMoveType = 'reordered' | 'moved'; + +interface NgxAccessibleDragAndDropBaseMessage< + KeyType extends keyof NgxAccessibleDragAndDropMessageRecord, + DataType +> { + type: KeyType; + data: DataType; +} + +export type NgxAccessibleDragAndDropMessage = + | NgxAccessibleDragAndDropBaseMessage< + 'selected' | 'deselected' | 'cancelled', + { item: string; itemLabel?: string } + > + | NgxAccessibleDragAndDropBaseMessage< + 'reordered' | 'moved', + { + item: string; + from: string; + to: string; + itemLabel?: string; + toLabel?: string; + fromLabel?: string; + } + >; + +export interface NgxAccessibleDragAndDropMoveEvent { + previousIndex: number; + newIndex: number; + previousContainer: number; + newContainer: number; +} diff --git a/libs/layout/src/lib/types/index.ts b/libs/layout/src/lib/types/index.ts index 1283674a..c2de7d37 100644 --- a/libs/layout/src/lib/types/index.ts +++ b/libs/layout/src/lib/types/index.ts @@ -1,3 +1,4 @@ export * from './configurable-layout'; export * from './display-content.types'; export * from './accordion.types'; +export * from './drag-and-drop.types'; diff --git a/libs/layout/src/lib/utils/hide-element/hide-element.util.ts b/libs/layout/src/lib/utils/hide-element/hide-element.util.ts new file mode 100644 index 00000000..30860e4d --- /dev/null +++ b/libs/layout/src/lib/utils/hide-element/hide-element.util.ts @@ -0,0 +1,12 @@ +/** + * Hides the element for users without assistive technologies + * + * @param {HTMLElement} element - A provided HTMLElement + */ +export const hideElement = (element: HTMLElement): void => { + element.style.position = 'absolute'; + element.style.width = '1px'; + element.style.height = '1px'; + element.style.marginTop = '-1px'; + element.style.overflow = 'hidden'; +}; diff --git a/libs/layout/src/lib/utils/index.ts b/libs/layout/src/lib/utils/index.ts new file mode 100644 index 00000000..fc9cb335 --- /dev/null +++ b/libs/layout/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './hide-element/hide-element.util'; diff --git a/libs/table/README.md b/libs/table/README.md index ca5a7015..ac258f95 100644 --- a/libs/table/README.md +++ b/libs/table/README.md @@ -265,7 +265,7 @@ Optionally, you can provide an array of `actions` in the same vain you can add ` ``` -As mentioned earlier, the `columns` input defines the order of the columns by using the order of the provided strings. These columns can be changed on the fly, allowing users to reorder the columns by hand or showing/hiding columns in certain specific situations. A built-in solution to allow drag-and-drop column sorting is not yet provided, but will be in the future. +As mentioned earlier, the `columns` input defines the order of the columns by using the order of the provided strings. These columns can be changed on the fly, allowing users to reorder the columns by hand or showing/hiding columns in certain specific situations. A built-in solution to allow drag and drop column sorting is not yet provided, but will be in the future. ### Header