Update Dropdown Focus Behavior to Be More Accessible

This commit is contained in:
copecone committed 2026-04-21 15:52:20 +09:00
1 parent c21845a579
commit 9092c97b9c
3 files changed
+58 -35

No files matched your search

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