Skip to content

Commit

Permalink
feat: accessibility of select2
Browse files Browse the repository at this point in the history
  • Loading branch information
mbelin-hvs committed Dec 17, 2024
1 parent b492d26 commit 4d7befc
Show file tree
Hide file tree
Showing 8 changed files with 456 additions and 250 deletions.
70 changes: 46 additions & 24 deletions projects/ng-select2-component/src/lib/select2-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,51 +40,44 @@ export class Select2Utils {
return Select2Utils.getOptionByValue(data, value as Select2Value);
}

static getFirstAvailableOption(data: Select2Data) {
static getFirstAvailableOption(data: Select2Data): Select2Option | null {
if (Array.isArray(data)) {
for (const groupOrOption of data) {
const options = (groupOrOption as Select2Group).options;
if (options) {
for (const option of options) {
if (!option.disabled) {
return option.value;
return option;
}
}
} else {
const option = groupOrOption as Select2Option;
if (!option.disabled) {
return option.value;
return option;
}
}
}
}
return null;
}

static valueIsNotInFilteredData(filteredData: Select2Data, value: Select2Value) {
if (Select2Utils.isNullOrUndefined(value)) {
static optionIsNotInFilteredData(filteredData: Select2Data, option: Select2Option | null): boolean {
if (Select2Utils.isNullOrUndefined(option)) {
return true;
}
for (const groupOrOption of filteredData) {
const options = (groupOrOption as Select2Group).options;
if (options) {
for (const option of options) {
if (option.value === value) {
return false;
}
}
} else if ((groupOrOption as Select2Option).value === value) {
if (options && options.includes(option)) {
return false;
} else if (groupOrOption === option) {
return false;
}
}
return true;
}

static getPreviousOption(
filteredData: Select2Data ,
hoveringValue: Select2Value ,
): Select2Option | null {
let findIt = Select2Utils.isNullOrUndefined(hoveringValue);
static getPreviousOption(filteredData: Select2Data, hoveringOption: Select2Option | null): Select2Option | null {
let findIt = Select2Utils.isNullOrUndefined(hoveringOption);
for (let i = filteredData.length - 1; i >= 0; i--) {
const groupOrOption = filteredData[i];
const options = (groupOrOption as Select2Group).options;
Expand All @@ -95,7 +88,7 @@ export class Select2Utils {
return option;
}
if (!findIt) {
findIt = option.value === hoveringValue;
findIt = option === hoveringOption;
}
}
} else {
Expand All @@ -104,15 +97,15 @@ export class Select2Utils {
return option;
}
if (!findIt) {
findIt = option.value === hoveringValue;
findIt = option === hoveringOption;
}
}
}
return null;
}

static getNextOption(filteredData: Select2Data | null, hoveringValue: Select2Value) {
let findIt = Select2Utils.isNullOrUndefined(hoveringValue);
static getNextOption(filteredData: Select2Data | null, hoveringOption: Select2Option | null): Select2Option | null {
let findIt = Select2Utils.isNullOrUndefined(hoveringOption);
if (filteredData) {
for (const groupOrOption of filteredData) {
const options = (groupOrOption as Select2Group).options;
Expand All @@ -123,7 +116,7 @@ export class Select2Utils {
return option;
}
} else if (!findIt) {
findIt = option.value === hoveringValue;
findIt = option === hoveringOption;
}
}
} else {
Expand All @@ -133,14 +126,43 @@ export class Select2Utils {
return option;
}
} else if (!findIt) {
findIt = option.value === hoveringValue;
findIt = option === hoveringOption;
}
}
}
}
return null;
}

static getFirstOption(filteredData: Select2Data): Select2Option | null {
const firstElement = filteredData[0];
if (this.isOption(firstElement)) {
return firstElement ?? null;
} else {
return firstElement.options[0] ?? null;
}
}

static getLastOption(filteredData: Select2Data): Select2Option | null {
const lastElement = filteredData.at(-1);
if (!lastElement) {
return null;
}
if (this.isOption(lastElement)) {
return lastElement;
} else {
return lastElement.options.at(-1) ?? null;
}
}

static isGroup(element: Select2Group | Select2Option): element is Select2Group {
return !!(element as Select2Group).options;
}

static isOption(element: Select2Group | Select2Option): element is Select2Option {
return !this.isGroup(element);
}

static getReduceData(data: Select2Data, maxResults = 0): { result: Select2Data; reduce: boolean } {
if (maxResults > 0) {
let counter = 0;
Expand Down Expand Up @@ -271,7 +293,7 @@ export class Select2Utils {
return count;
}

private static isNullOrUndefined(value: any) {
private static isNullOrUndefined(value: any): value is null | undefined {
return value === null || value === undefined;
}

Expand Down
103 changes: 72 additions & 31 deletions projects/ng-select2-component/src/lib/select2.component.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<div class="select2-label" (click)="toggleOpenAndClose()">
<label class="select2-label" (click)="toggleOpenAndClose()" [id]="idLabel()">
<ng-content select="select2-label"></ng-content>
@if (required()) {
<span class="select2-required"></span>
<span class="select2-required" aria-hidden="true"></span>
}
</div>
</label>

<div
class="select2 select2-container select2-container--default"
Expand All @@ -12,15 +12,29 @@
[class.select2-container--above]="select2above"
[class.select2-container--open]="isOpen"
[class.select2-container--disabled]="disabled()"
[class.select2-container--readonly]="readonly()"
>
<div
[id]="idCombo()"
role="combobox"
class="selection"
#selection
#trigger="cdkOverlayOrigin"
[tabindex]="!this.isOpen ? _tabIndex : '-1'"
[attr.aria-labelledby]="ariaLabelledby() ?? idLabel()"
[attr.aria-expanded]="isOpen"
aria-haspopup="listbox"
[attr.aria-controls]="idOptions()"
[attr.aria-activedescendant]="isOpen ? hoveringOptionId() : null"
[attr.aria-describedby]="ariaDescribedby()"
[attr.title]="title()"
[attr.aria-invalid]="_isErrorState() || ariaInvalid() ? 'true' : null"
[attr.aria-required]="required() ? 'true' : null"
[attr.aria-readonly]="readonly() ? 'true' : null"
[attr.aria-disabled]="disabled() ? 'true' : null"
(click)="toggleOpenAndClose(); $event.stopPropagation()"
(focus)="focusin()"
(blur)="focusout()"
(focusout)="focusout($event)"
(keydown)="openKey($event)"
cdkOverlayOrigin
[class.select2-focused]="focused"
Expand All @@ -29,18 +43,15 @@
class="select2-selection"
[class.select2-selection--multiple]="multiple()"
[class.select2-selection--single]="!multiple()"
role="combobox"
>
@if (selectionOverride()) {
<span class="select2-selection__override" [innerHTML]="_selectionOverrideLabel()"></span>

@if (resettable() && resetSelectedValue() !== _value && select2Option && !(disabled() || readonly())) {
<span (click)="reset($event)" class="select2-selection__reset" role="presentation">×</span>
}
@if (
!multiple() && resettable() && resetSelectedValue() !== _value && select2Option && !(disabled || readonly())
resettable() && !(disabled() || readonly()) && resetSelectedValue() !== _value &&
((!multiple() && select2Option) || (multiple() && select2Options.length > 0))
) {
<span (click)="reset($event)" class="select2-selection__reset" role="presentation">×</span>
<ng-container *ngTemplateOutlet="resetButton"></ng-container>
}
} @else if (!multiple()) {
<span class="select2-selection__rendered" [title]="select2Option?.label || ''">
Expand All @@ -62,14 +73,14 @@
</span>

@if (resettable() && resetSelectedValue() !== _value && select2Option && !(disabled() || readonly())) {
<span (click)="reset($event)" class="select2-selection__reset" role="presentation">×</span>
<ng-container *ngTemplateOutlet="resetButton"></ng-container>
}
<span class="select2-selection__arrow" role="presentation"> </span>
} @else {
<ul class="select2-selection__rendered">
@if (!autoCreate()) {
<span
[class.select2-selection__placeholder__option]="select2Options && select2Options.length > 0"
[class.select2-selection__placeholder__option]="select2Options.length > 0"
class="select2-selection__placeholder"
>{{ placeholder() }}</span
>
Expand All @@ -79,13 +90,15 @@
class="select2-selection__choice"
[title]="op.label"
tabindex="0"
(focus)="_updateFocusState(true)"
(keydown.enter)="removeSelection($event, op)"
>
@if (!(disabled() || readonly())) {
<span
(click)="removeSelection($event, op)"
class="select2-selection__choice__remove"
role="presentation"
aria-hidden="true"
>×</span
>
}
Expand All @@ -99,7 +112,7 @@
@if (autoCreate()) {
<li class="select2-selection__auto-create" (focus)="stopEvent($event)" (blur)="stopEvent($event)">
<input
[id]="_id + '-create-field'"
[id]="id() + '-create-field'"
(click)="toggleOpenAndClose(false, true); stopEvent($event)"
(keydown)="keyDown($event, true)"
(keyup)="searchUpdate($event)"
Expand Down Expand Up @@ -143,6 +156,7 @@

<ng-template #containerTemplate>
<div
[id]="idOverlay()"
class="select2-container select2-container--default select2-container-dropdown"
[class.select2-container--open]="isOpen"
[class.select2-overlay]="overlay()"
Expand All @@ -155,40 +169,49 @@
[class.select2-dropdown--below]="!select2above"
[class.select2-dropdown--above]="select2above"
>
<div class="select2-search select2-search--dropdown" [class.select2-search--hide]="hideSearch()">
<div class="select2-search select2-search--dropdown" [class.select2-search--hide]="isSearchboxHidden">
<input
#searchInput
[id]="_id + '-search-field'"
[id]="id() + '-search-field'"
[value]="searchText"
(keydown)="keyDown($event, autoCreate())"
(keyup)="searchUpdate($event)"
(change)="prevChange($event)"
class="select2-search__field"
type="search"
role="textbox"
role="combobox"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
[attr.tabindex]="this.isOpen ? _tabIndex : '-1'"
[attr.aria-labelledby]="ariaLabelledby() ?? idLabel()"
aria-autocomplete="list"
[attr.aria-controls]="idOptions()"
aria-expanded="true"
[attr.aria-activedescendant]="hoveringOptionId()"
/>
</div>

<div class="select2-results">
<ul
[id]="idOptions()"
#results
class="select2-results__options"
[class.select2-grid]="grid() && isNumber(grid())"
[class.select2-grid-auto]="grid() && !isNumber(grid())"
[style.max-height]="resultMaxHeight()"
[style.--grid-size]="grid() || null"
role="tree"
role="listbox"
tabindex="-1"
infiniteScroll
[infiniteScrollDisabled]="!infiniteScroll() && !isOpen"
[infiniteScrollDistance]="infiniteScrollDistance()"
[infiniteScrollThrottle]="infiniteScrollThrottle()"
[infiniteScrollContainer]="results"
[attr.aria-labelledby]="ariaLabelledby() ?? idLabel()"
[attr.aria-multiselectable]="multiple()"
[attr.aria-activedescendant]="hoveringOptionId()"
(scrolled)="onScroll('down')"
(scrolledUp)="onScroll('up')"
(keydown)="keyDown($event)"
Expand All @@ -205,22 +228,26 @@
@for (groupOrOption of filteredData(); track groupOrOption; let i = $index) {
@let group = _toGroup(groupOrOption);
@if (group.options !== undefined) {
<li class="select2-results__option" role="group">
@if (!hasTemplate(group, 'group')) {
<strong
[attr.class]="'select2-results__group' + (group.classes ? ' ' + group.classes : '')"
[innerHTML]="group.label"
></strong>
} @else {
<ng-container *ngTemplateOutlet="getTemplate(group, 'group'); context: group"> </ng-container>
}
<ul class="select2-results__options select2-results__options--nested">
<li class="select2-results__option select2-results__group">
<span [id]="getElementId(groupOrOption)">
@if (!hasTemplate(group, 'group')) {
<strong
[attr.class]="'select2-results__group' + (group.classes ? ' ' + group.classes : '')"
[innerHTML]="group.label"
></strong>
} @else {
<ng-container *ngTemplateOutlet="getTemplate(group, 'group'); context: group"> </ng-container>
}
</span>
<ul class="select2-results__options select2-results__options--nested"
role="group"
[attr.aria-labelledby]="getElementId(groupOrOption)">
@for (option of group.options; track option; let j = $index) {
<li
#result
[id]="option.id || _id + '-option-' + i + '-' + j"
[id]="getElementId(option)"
[class]="getOptionStyle(option)"
role="treeitem"
role="option"
[attr.aria-selected]="isSelected(option)"
[attr.aria-disabled]="isDisabled(option)"
(mouseenter)="mouseenter(option)"
Expand All @@ -240,9 +267,9 @@
@let option = _toOption(groupOrOption);
<li
#result
[id]="option.id || _id + '-option-' + i"
[id]="getElementId(groupOrOption)"
[class]="getOptionStyle(option)"
role="treeitem"
role="option"
[attr.aria-selected]="isSelected(option)"
[attr.aria-disabled]="isDisabled(option)"
(mouseenter)="mouseenter(option)"
Expand Down Expand Up @@ -271,3 +298,17 @@
</div>
</div>
</ng-template>

<ng-template #resetButton>
<button
type="button"
(focus)="_updateFocusState(true)"
(click)="reset($event)"
(keydown)="$event.stopPropagation()"
class="select2-selection__reset"
[attr.aria-description]="ariaResetButtonDescription()"
[attr.aria-controls]="idCombo()"
>
<span aria-hidden="true">×</span>
</button>
</ng-template>
Loading

0 comments on commit 4d7befc

Please sign in to comment.