Compare commits

..
2 Commits
Author SHA1 Message Date
copecone 0586005191 Improve Settings Popup Accessibility 2026-04-21 15:54:00 +09:00
copecone 9092c97b9c Update Dropdown Focus Behavior to Be More Accessible 2026-04-21 15:52:20 +09:00
8 changed files with 107 additions and 74 deletions

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()) {
+22 -24
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,33 +61,31 @@ $default_animation_duration: 0.2s;
.placeholder { .placeholder {
text-overflow: ellipsis; text-overflow: ellipsis;
} &.value-empty { opacity: 50%; }
.dropdown-input:focus-visible {
outline: none;
}
.dropdown-input:empty:before {
content: attr(placeholder);
opacity: 50%;
}
.dropdown-input:not(.focus-mode) {
caret-color: transparent;
user-select: none;
}
.dropdown-input.hidden {
position: absolute;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
} }
.dropdown-input { .dropdown-input {
cursor: text; cursor: text;
&:focus-visible { outline: none; }
&:empty:before {
content: attr(placeholder);
opacity: 50%;
}
&:not(.focus-mode) {
caret-color: transparent;
user-select: none;
}
&.hidden {
position: absolute;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
}
} }
.placeholder, .dropdown-input { .placeholder, .dropdown-input {
+46 -22
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,23 +166,38 @@ 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()) { if (this.isOpen()) this.element.nativeElement.tabIndex = -1;
const searchInputElement = this.searchInput()!.nativeElement; }
searchInputElement.focus(); onInputFocusOut() {
this.setCursorToEnd(searchInputElement); 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) { private checkIfInputFocus(event: Event) {
let triggerInputFocus = this.searchInput()!.nativeElement.contains( let triggerInputFocus = this.searchInput()!.nativeElement.contains(
event.target! as HTMLElement, event.target! as HTMLElement,
@@ -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();
}); });
+2
View File
@@ -0,0 +1,2 @@
<label class="category-header">{{ headerLabel() }}</label>
<ng-content></ng-content>
+9
View File
@@ -0,0 +1,9 @@
:host {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
justify-content: flex-start;
margin: 0; padding: 0;
border: none;
}
@@ -5,9 +5,7 @@
</div> </div>
<div class="settings-content"> <div class="settings-content">
<section class="settings-category"> <settings-category [headerLabel]="translation.settings.language">
<label class="category-header">{{ translation.settings.language }}</label>
<div class="settings-container"> <div class="settings-container">
<dropdown [options]="languages" <dropdown [options]="languages"
[value]="settingsService.language()" [value]="settingsService.language()"
@@ -17,18 +15,17 @@
<div class="settings-container"> <div class="settings-container">
<div class="settings-name"> <div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span> <span class="material-symbols-outlined icon-24 settings-icon" aria-hidden="true">match_case</span>
<span>{{ translation.settings.romanisation }}</span> <span>{{ translation.settings.romanisation }}</span>
</div> </div>
<switch [isActive]="settingsService.romanisation()" (toggle)="onRomanisationChange($event)"></switch> <switch [isActive]="settingsService.romanisation()" (toggle)="onRomanisationChange($event)"></switch>
</div> </div>
</section> </settings-category>
<section class="settings-category"> <settings-category [headerLabel]="translation.settings.appearance">
<label class="category-header">{{ translation.settings.appearance }}</label>
<div class="settings-container"> <div class="settings-container">
<div class="settings-name"> <div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span> <span class="material-symbols-outlined icon-24 settings-icon" aria-hidden="true">dark_mode</span>
<span>{{ translation.settings.darkMode }}</span> <span>{{ translation.settings.darkMode }}</span>
</div> </div>
<switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch> <switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch>
@@ -36,12 +33,12 @@
<div class="settings-container"> <div class="settings-container">
<div class="settings-name"> <div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">motion_photos_paused</span> <span class="material-symbols-outlined icon-24 settings-icon" aria-hidden="true">motion_photos_paused</span>
<span>{{ translation.settings.reduceAnimation }}</span> <span>{{ translation.settings.reduceAnimation }}</span>
</div> </div>
<switch [isActive]="settingsService.reduceAnimation()" (toggle)="onReduceAnimationChange($event)"></switch> <switch [isActive]="settingsService.reduceAnimation()" (toggle)="onReduceAnimationChange($event)"></switch>
</div> </div>
</section> </settings-category>
</div> </div>
</app-popup> </app-popup>
@@ -25,15 +25,7 @@
padding-bottom: 12px; padding-bottom: 12px;
section.settings-category { settings-category {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
justify-content: flex-start;
margin: 0; padding: 0;
border: none;
label { label {
display: block; display: block;
font-weight: 500; font-weight: 500;
+13 -3
View File
@@ -7,12 +7,22 @@ import { PopupService } from '../../services/popup.service';
import { SettingsService, Language } from '../../services/settings.service'; import { SettingsService, Language } from '../../services/settings.service';
import { TranslationService } from '../../services/translation.service'; import { TranslationService } from '../../services/translation.service';
@Component({
selector: 'settings-category',
standalone: true,
templateUrl: './category.html',
styleUrl: './category.scss',
})
export class SettingsCategory {
headerLabel = input<string>();
}
@Component({ @Component({
selector: 'settings-popup', selector: 'settings-popup',
standalone: true, standalone: true,
imports: [PopupComponent, Dropdown, Switch, NgOptimizedImage], imports: [PopupComponent, Dropdown, Switch, NgOptimizedImage, SettingsCategory],
templateUrl: './popup.html', templateUrl: './settings.html',
styleUrl: './popup.scss', styleUrl: './settings.scss',
}) })
export class SettingsPopup { export class SettingsPopup {
private popupService = inject(PopupService); private popupService = inject(PopupService);