Update Dropdown Focus Behavior to Be More Accessible
This commit is contained in:
1 parent
c21845a579
commit
9092c97b9c
3 files changed
+58
-35
No files matched your search
@@ -1,22 +1,23 @@
|
|||||||
<ng-template #inputTemplate>
|
<ng-template #inputTemplate>
|
||||||
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
|
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
|
||||||
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
|
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
|
||||||
[attr.placeholder]="translation.common.selectOption" tabindex="-1" spellcheck="false"
|
[attr.placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" tabindex="-1" spellcheck="false"
|
||||||
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"></div>
|
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"
|
||||||
|
(focus)="onInputFocusIn()" (focusout)="onInputFocusOut()"></div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<div class="dropdown-box">
|
<div class="dropdown-box">
|
||||||
@if (icon()) {
|
@if (icon()) {
|
||||||
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
|
<span class="material-symbols-outlined icon-24 prefix-icon" aria-hidden="true">{{ icon() }}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
@if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
||||||
@else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
@else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
||||||
|
|
||||||
@if (!isOpen()) {
|
@if (!isOpen()) {
|
||||||
<span class="placeholder">{{ selectedLabel() }}</span>
|
<span class="placeholder" [class.value-empty]="selectedOption() === undefined">{{ selectedLabel() }}</span>
|
||||||
}
|
}
|
||||||
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span>
|
<span class="material-symbols-outlined icon-24 chevron" aria-hidden="true" [class.open]="isOpen()">keyboard_arrow_down</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isOpen()) {
|
@if (isOpen()) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ $default_animation_duration: 0.2s;
|
|||||||
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
&:focus-within {
|
&:has(.dropdown-input.focus-mode:focus-visible) {
|
||||||
outline: 2px solid list.nth(library.$brand_palette, 5);
|
outline: 2px solid list.nth(library.$brand_palette, 5);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -61,23 +61,24 @@ $default_animation_duration: 0.2s;
|
|||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
&.value-empty { opacity: 50%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-input:focus-visible {
|
.dropdown-input {
|
||||||
outline: none;
|
cursor: text;
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-input:empty:before {
|
&:focus-visible { outline: none; }
|
||||||
|
&:empty:before {
|
||||||
content: attr(placeholder);
|
content: attr(placeholder);
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-input:not(.focus-mode) {
|
&:not(.focus-mode) {
|
||||||
caret-color: transparent;
|
caret-color: transparent;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-input.hidden {
|
&.hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
width: 0;
|
width: 0;
|
||||||
@@ -85,9 +86,6 @@ $default_animation_duration: 0.2s;
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-input {
|
|
||||||
cursor: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder, .dropdown-input {
|
.placeholder, .dropdown-input {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
output,
|
|
||||||
signal,
|
|
||||||
HostListener,
|
|
||||||
ElementRef,
|
|
||||||
computed,
|
|
||||||
OnInit,
|
OnInit,
|
||||||
inject,
|
output,
|
||||||
effect,
|
|
||||||
viewChild,
|
|
||||||
viewChildren,
|
|
||||||
untracked,
|
|
||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
|
signal,
|
||||||
|
untracked,
|
||||||
|
viewChild,
|
||||||
|
viewChildren
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
import { TranslationService } from '../../services/translation.service';
|
import { TranslationService } from '../../services/translation.service';
|
||||||
@@ -39,7 +39,7 @@ export interface DropdownOption {
|
|||||||
'[class.open]': 'isOpen()',
|
'[class.open]': 'isOpen()',
|
||||||
tabindex: '0',
|
tabindex: '0',
|
||||||
'(click)': 'toggle()',
|
'(click)': 'toggle()',
|
||||||
'(focus)': 'onFocus()',
|
'(focusin)': 'onFocusIn($event)',
|
||||||
'(mousedown)': 'onMouseDown($event)',
|
'(mousedown)': 'onMouseDown($event)',
|
||||||
'(keydown)': 'onKeyDown($event)',
|
'(keydown)': 'onKeyDown($event)',
|
||||||
'(keydown.enter)': 'onEnter($event)',
|
'(keydown.enter)': 'onEnter($event)',
|
||||||
@@ -56,7 +56,7 @@ export class Dropdown implements OnInit {
|
|||||||
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
|
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
|
||||||
|
|
||||||
options = input<Array<DropdownOption | string>>([]);
|
options = input<Array<DropdownOption | string>>([]);
|
||||||
placeholder = input<string>('Select an option');
|
placeholder = input<string>('');
|
||||||
ariaLabel = input<string | undefined>(undefined);
|
ariaLabel = input<string | undefined>(undefined);
|
||||||
icon = input<string>();
|
icon = input<string>();
|
||||||
defaultIndex = input<number>();
|
defaultIndex = input<number>();
|
||||||
@@ -90,7 +90,7 @@ export class Dropdown implements OnInit {
|
|||||||
if (isDropdownOpen && index >= 0) {
|
if (isDropdownOpen && index >= 0) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.scrollToIndex(index);
|
this.scrollToIndex(index);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,22 +166,37 @@ export class Dropdown implements OnInit {
|
|||||||
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
|
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const searchInputElement = this.searchInput()!.nativeElement;
|
let wasFocusVisible = this.element.nativeElement.matches(':focus-visible');
|
||||||
|
this.focusSearchInput();
|
||||||
|
|
||||||
searchInputElement.focus();
|
if (wasFocusVisible) this.inputFocusMode.set(true);
|
||||||
this.setCursorToEnd(searchInputElement);
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onFocus() {
|
onInputFocusIn() {
|
||||||
|
if (this.isOpen()) this.element.nativeElement.tabIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputFocusOut() {
|
||||||
|
if (this.isOpen()) this.element.nativeElement.tabIndex = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusIn(event: FocusEvent) {
|
||||||
|
if (this.element.nativeElement.contains(event.relatedTarget)) return;
|
||||||
|
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
|
this.focusSearchInput();
|
||||||
|
this.inputFocusMode.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusSearchInput() {
|
||||||
const searchInputElement = this.searchInput()!.nativeElement;
|
const searchInputElement = this.searchInput()!.nativeElement;
|
||||||
|
|
||||||
searchInputElement.focus();
|
searchInputElement.focus();
|
||||||
this.setCursorToEnd(searchInputElement);
|
this.setCursorToEnd(searchInputElement);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private checkIfInputFocus(event: Event) {
|
private checkIfInputFocus(event: Event) {
|
||||||
let triggerInputFocus = this.searchInput()!.nativeElement.contains(
|
let triggerInputFocus = this.searchInput()!.nativeElement.contains(
|
||||||
@@ -198,6 +213,7 @@ export class Dropdown implements OnInit {
|
|||||||
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
|
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
|
||||||
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
|
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
|
||||||
|
|
||||||
|
this.element.nativeElement.tabIndex = undefined;
|
||||||
this.focusedIndex.set(-1);
|
this.focusedIndex.set(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +250,8 @@ export class Dropdown implements OnInit {
|
|||||||
|
|
||||||
onArrowDown(event: Event) {
|
onArrowDown(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
@@ -250,6 +268,8 @@ export class Dropdown implements OnInit {
|
|||||||
|
|
||||||
onArrowUp(event: Event) {
|
onArrowUp(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
@@ -332,8 +352,12 @@ export class Dropdown implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedOption = computed(() => {
|
||||||
|
return this.normalizedOptions().find((o) => o.value === this.value());
|
||||||
|
})
|
||||||
|
|
||||||
selectedLabel = computed(() => {
|
selectedLabel = computed(() => {
|
||||||
const selected = this.normalizedOptions().find((o) => o.value === this.value());
|
const selected = this.selectedOption();
|
||||||
return selected ? selected.label : this.placeholder();
|
return selected ? selected.label : this.placeholder();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user