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> <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()) {
+8 -10
View File
@@ -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 {
+43 -19
View File
@@ -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();
}); });