feat: Multiple Features, Accessibility, Security, and UX Improvements #59

Merged
naomi merged 27 commits from feat/polish into main 2026-02-20 01:51:25 -08:00
11 changed files with 291 additions and 84 deletions
Showing only changes of commit 6f4c3bd41a - Show all commits
+2 -1
View File
@@ -1,5 +1,6 @@
<a href="#main-content" class="skip-link">Skip to main content</a>
<app-header></app-header> <app-header></app-header>
<main class="main-content"> <main id="main-content" class="main-content" tabindex="-1">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<app-footer></app-footer> <app-footer></app-footer>
@@ -12,24 +12,24 @@ import { CommonModule } from '@angular/common';
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<footer class="footer"> <footer class="footer" role="contentinfo">
<div class="footer-content"> <div class="footer-content">
<div class="cta-section"> <div class="cta-section">
<div class="cta-card discord"> <section class="cta-card discord" aria-labelledby="discord-heading">
<h3>Join the Community</h3> <h2 id="discord-heading">Join the Community</h2>
<p>Want to chat about what we're reading, playing, or listening to? Got recommendations? Come hang out!</p> <p>Want to chat about what we're reading, playing, or listening to? Got recommendations? Come hang out!</p>
<a href="https://chat.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-discord"> <a href="https://chat.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-discord">
Join our Discord Join our Discord<span class="sr-only"> (opens in new window)</span>
</a> </a>
</div> </section>
<div class="cta-card donate"> <section class="cta-card donate" aria-labelledby="donate-heading">
<h3>Support Naomi</h3> <h2 id="donate-heading">Support Naomi</h2>
<p>Enjoying the vibes? Your support helps keep the servers running and the coffee flowing!</p> <p>Enjoying the vibes? Your support helps keep the servers running and the coffee flowing!</p>
<a href="https://donate.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-donate"> <a href="https://donate.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-donate">
Buy us a coffee Buy us a coffee<span class="sr-only"> (opens in new window)</span>
</a> </a>
</div> </section>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
@@ -73,7 +73,7 @@ import { CommonModule } from '@angular/common';
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
} }
.cta-card h3 { .cta-card h2 {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--witch-moon); color: var(--witch-moon);
@@ -131,6 +131,18 @@ import { CommonModule } from '@angular/common';
font-size: 0.9rem; font-size: 0.9rem;
color: var(--witch-lavender); color: var(--witch-lavender);
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`] `]
}) })
export class FooterComponent { export class FooterComponent {
@@ -503,39 +503,49 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</form> </form>
} }
<div class="search-section"> <div class="search-section" role="search" aria-label="Game search and filters">
<label for="game-search" class="sr-only">Search games</label>
<input <input
type="text" type="search"
id="game-search"
[value]="searchQuery()" [value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value); currentPage.set(1)" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)"
name="search" name="search"
placeholder="Search by title, platform, or notes..." placeholder="Search by title, platform, or notes..."
class="search-input" class="search-input"
aria-label="Search by title, platform, or notes"
>
<button
(click)="toggleFilters()"
class="btn btn-secondary btn-sm"
[attr.aria-expanded]="showFilters()"
aria-controls="advanced-filters"
type="button"
> >
<button (click)="toggleFilters()" class="btn btn-secondary btn-sm">
{{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters {{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters
@if (selectedTags().length > 0) { @if (selectedTags().length > 0) {
({{ selectedTags().length }}) <span aria-label="{{ selectedTags().length }} active filters">({{ selectedTags().length }})</span>
} }
</button> </button>
@if (searchQuery() || selectedTags().length > 0) { @if (searchQuery() || selectedTags().length > 0) {
<button (click)="clearFilters()" class="btn btn-secondary btn-sm"> <button (click)="clearFilters()" class="btn btn-secondary btn-sm" type="button">
Clear All Filters Clear All Filters
</button> </button>
} }
</div> </div>
@if (showFilters()) { @if (showFilters()) {
<div class="advanced-filters"> <div class="advanced-filters" id="advanced-filters" role="region" aria-label="Advanced filters">
<div class="filter-group"> <div class="filter-group">
<h4>Filter by Tags</h4> <h3>Filter by Tags</h3>
<div class="tags-filter"> <div class="tags-filter" role="group" aria-label="Tag filters">
@for (tag of allTags(); track tag) { @for (tag of allTags(); track tag) {
<label class="tag-checkbox"> <label class="tag-checkbox">
<input <input
type="checkbox" type="checkbox"
[checked]="selectedTags().includes(tag)" [checked]="selectedTags().includes(tag)"
(change)="toggleTag(tag)" (change)="toggleTag(tag)"
[attr.aria-label]="'Filter by ' + tag"
> >
<span>{{ tag }}</span> <span>{{ tag }}</span>
</label> </label>
@@ -548,39 +558,49 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div> </div>
} }
<div class="filters"> <div class="filters" role="group" aria-label="Filter games by status">
<button <button
(click)="setFilter('all')" (click)="setFilter('all')"
[class.active]="statusFilter() === 'all'" [class.active]="statusFilter() === 'all'"
[attr.aria-pressed]="statusFilter() === 'all'"
class="filter-btn" class="filter-btn"
type="button"
> >
All ({{ games().length }}) All ({{ games().length }})
</button> </button>
<button <button
(click)="setFilter(GameStatus.playing)" (click)="setFilter(GameStatus.playing)"
[class.active]="statusFilter() === GameStatus.playing" [class.active]="statusFilter() === GameStatus.playing"
[attr.aria-pressed]="statusFilter() === GameStatus.playing"
class="filter-btn" class="filter-btn"
type="button"
> >
Playing ({{ playingCount() }}) Playing ({{ playingCount() }})
</button> </button>
<button <button
(click)="setFilter(GameStatus.completed)" (click)="setFilter(GameStatus.completed)"
[class.active]="statusFilter() === GameStatus.completed" [class.active]="statusFilter() === GameStatus.completed"
[attr.aria-pressed]="statusFilter() === GameStatus.completed"
class="filter-btn" class="filter-btn"
type="button"
> >
Completed ({{ completedCount() }}) Completed ({{ completedCount() }})
</button> </button>
<button <button
(click)="setFilter(GameStatus.backlog)" (click)="setFilter(GameStatus.backlog)"
[class.active]="statusFilter() === GameStatus.backlog" [class.active]="statusFilter() === GameStatus.backlog"
[attr.aria-pressed]="statusFilter() === GameStatus.backlog"
class="filter-btn" class="filter-btn"
type="button"
> >
Backlog ({{ backlogCount() }}) Backlog ({{ backlogCount() }})
</button> </button>
<button <button
(click)="setFilter(GameStatus.retired)" (click)="setFilter(GameStatus.retired)"
[class.active]="statusFilter() === GameStatus.retired" [class.active]="statusFilter() === GameStatus.retired"
[attr.aria-pressed]="statusFilter() === GameStatus.retired"
class="filter-btn" class="filter-btn"
type="button"
> >
Retired ({{ retiredCount() }}) Retired ({{ retiredCount() }})
</button> </button>
@@ -1061,6 +1081,18 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
input[type="file"]:hover { input[type="file"]:hover {
border-color: #10b981; border-color: #10b981;
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`] `]
}) })
export class GamesListComponent implements OnInit { export class GamesListComponent implements OnInit {
@@ -6,7 +6,7 @@
import { Component, inject, signal, OnInit } from '@angular/core'; import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router'; import { RouterModule, Router } from '@angular/router';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
@@ -16,58 +16,67 @@ import { ApiService } from '../../services/api.service';
imports: [CommonModule, RouterModule], imports: [CommonModule, RouterModule],
template: ` template: `
<header class="header"> <header class="header">
<nav class="navbar"> <nav class="navbar" aria-label="Main navigation">
<div class="nav-brand"> <div class="nav-brand">
<img src="/assets/icons/icon-72x72.png" alt="Naomi's Library" class="brand-icon" /> <img src="/assets/icons/icon-72x72.png" alt="" class="brand-icon" role="presentation" />
<h1><a routerLink="/">Naomi's Library</a></h1> <h1><a routerLink="/">Naomi's Library</a></h1>
@if (version()) { @if (version()) {
<span class="version">v{{ version() }}</span> <span class="version" aria-label="Version {{ version() }}">v{{ version() }}</span>
} }
</div> </div>
<ul class="nav-links"> <ul class="nav-links" role="list">
<li><a routerLink="/games" routerLinkActive="active">Games</a></li> <li><a routerLink="/games" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/games') ? 'page' : null">Games</a></li>
<li><a routerLink="/books" routerLinkActive="active">Books</a></li> <li><a routerLink="/books" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/books') ? 'page' : null">Books</a></li>
<li><a routerLink="/music" routerLinkActive="active">Music</a></li> <li><a routerLink="/music" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/music') ? 'page' : null">Music</a></li>
<li><a routerLink="/shows" routerLinkActive="active">Shows</a></li> <li><a routerLink="/shows" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/shows') ? 'page' : null">Shows</a></li>
<li><a routerLink="/manga" routerLinkActive="active">Manga</a></li> <li><a routerLink="/manga" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/manga') ? 'page' : null">Manga</a></li>
<li><a routerLink="/art" routerLinkActive="active">Art</a></li> <li><a routerLink="/art" routerLinkActive="active" [attr.aria-current]="isCurrentRoute('/art') ? 'page' : null">Art</a></li>
</ul> </ul>
<div class="auth-section"> <div class="auth-section">
@if (authService.user(); as user) { @if (authService.user(); as user) {
<div class="user-menu"> <div class="user-menu">
@if (user.avatar) { @if (user.avatar) {
<img <button
[src]="user.avatar" class="user-avatar-button"
[alt]="user.username" [attr.aria-label]="'User menu for ' + user.username"
class="user-avatar" [attr.aria-expanded]="showDropdown()"
aria-haspopup="true"
(click)="toggleDropdown()" (click)="toggleDropdown()"
(keyup.enter)="toggleDropdown()" (keydown.escape)="closeDropdown()"
(keyup.space)="toggleDropdown()" >
tabindex="0" <img
role="button" [src]="user.avatar"
/> [alt]="'Avatar for ' + user.username"
class="user-avatar"
/>
</button>
} }
@if (showDropdown()) { @if (showDropdown()) {
<div class="dropdown-menu"> <div
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a> class="dropdown-menu"
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a> role="menu"
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a> aria-label="User menu"
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a> (keydown.escape)="closeDropdown()"
<a routerLink="/activity" class="dropdown-item" (click)="closeDropdown()">📰 Activity Feed</a> >
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">️ About</a> <a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" role="menuitem" (click)="closeDropdown()">My Profile</a>
<a routerLink="/settings" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Settings</a>
<a routerLink="/achievements" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">🏆</span> Achievements</a>
<a routerLink="/leaderboard" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">🏆</span> Leaderboard</a>
<a routerLink="/activity" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">📰</span> Activity Feed</a>
<a routerLink="/about" class="dropdown-item" role="menuitem" (click)="closeDropdown()"><span aria-hidden="true">️</span> About</a>
@if (!user.isAdmin) { @if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a> <a routerLink="/my-suggestions" class="dropdown-item" role="menuitem" (click)="closeDropdown()">My Suggestions</a>
} }
<a routerLink="/my-likes" class="dropdown-item" (click)="closeDropdown()">My Likes</a> <a routerLink="/my-likes" class="dropdown-item" role="menuitem" (click)="closeDropdown()">My Likes</a>
@if (user.isAdmin) { @if (user.isAdmin) {
<a routerLink="/admin/users" class="dropdown-item" (click)="closeDropdown()">Users</a> <a routerLink="/admin/users" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Users</a>
<a routerLink="/admin/audit" class="dropdown-item" (click)="closeDropdown()">Audit</a> <a routerLink="/admin/audit" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Audit</a>
<a routerLink="/admin/suggestions" class="dropdown-item" (click)="closeDropdown()">Suggestions</a> <a routerLink="/admin/suggestions" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Suggestions</a>
<a routerLink="/admin/reports" class="dropdown-item" (click)="closeDropdown()">Reports</a> <a routerLink="/admin/reports" class="dropdown-item" role="menuitem" (click)="closeDropdown()">Reports</a>
} }
<button (click)="logout()" class="dropdown-item logout-btn">Logout</button> <button (click)="logout()" class="dropdown-item logout-btn" role="menuitem">Logout</button>
</div> </div>
} }
</div> </div>
@@ -172,20 +181,35 @@ import { ApiService } from '../../services/api.service';
position: relative; position: relative;
} }
.user-avatar-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
}
.user-avatar { .user-avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--witch-lavender); border: 2px solid var(--witch-lavender);
transition: all 0.3s; transition: all 0.3s;
cursor: pointer;
} }
.user-avatar:hover { .user-avatar-button:hover .user-avatar,
.user-avatar-button:focus .user-avatar {
border-color: var(--witch-moon); border-color: var(--witch-moon);
transform: scale(1.1); transform: scale(1.1);
} }
.user-avatar-button:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-radius: 50%;
}
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
top: 50px; top: 50px;
@@ -304,6 +328,7 @@ import { ApiService } from '../../services/api.service';
export class HeaderComponent implements OnInit { export class HeaderComponent implements OnInit {
authService = inject(AuthService); authService = inject(AuthService);
private apiService = inject(ApiService); private apiService = inject(ApiService);
private router = inject(Router);
version = signal<string | null>(null); version = signal<string | null>(null);
showDropdown = signal<boolean>(false); showDropdown = signal<boolean>(false);
@@ -314,6 +339,10 @@ export class HeaderComponent implements OnInit {
}); });
} }
isCurrentRoute(route: string): boolean {
return this.router.url.startsWith(route);
}
toggleDropdown() { toggleDropdown() {
this.showDropdown.update(v => !v); this.showDropdown.update(v => !v);
} }
@@ -17,8 +17,8 @@ import { ReportReason } from '@library/shared-types';
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
template: ` template: `
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="closeModal.emit()" tabindex="-1"> <div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="onClose()" tabindex="-1" role="presentation">
<div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true"> <div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-title">{{ modalTitle() }}</h2> <h2 id="modal-title">{{ modalTitle() }}</h2>
<button <button
@@ -23,10 +23,11 @@ import { take } from 'rxjs';
[class.liked]="liked()" [class.liked]="liked()"
[disabled]="loading() || !isAuthenticated()" [disabled]="loading() || !isAuthenticated()"
(click)="toggleLike()" (click)="toggleLike()"
[title]="getTitle()" [attr.aria-label]="getAriaLabel()"
[attr.aria-pressed]="liked()"
> >
<span class="heart-icon">{{ liked() ? '❤️' : '🤍' }}</span> <span class="heart-icon" aria-hidden="true">{{ liked() ? '❤️' : '🤍' }}</span>
<span class="like-count">{{ count() }}</span> <span class="like-count" aria-label="{{ count() }} likes">{{ count() }}</span>
</button> </button>
</div> </div>
`, `,
@@ -164,4 +165,15 @@ export class LikeButtonComponent implements OnInit {
} }
return this.liked() ? 'Unlike' : 'Like'; return this.liked() ? 'Unlike' : 'Like';
} }
getAriaLabel(): string {
const count = this.count();
const likeText = count === 1 ? 'like' : 'likes';
if (!this.isAuthenticated()) {
return `Sign in to like. ${count} ${likeText}`;
}
return this.liked()
? `Unlike. ${count} ${likeText}`
: `Like. ${count} ${likeText}`;
}
} }
@@ -12,39 +12,44 @@ import { CommonModule } from '@angular/common';
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div class="pagination-container"> <nav class="pagination-container" aria-label="Pagination">
<div class="pagination-info"> <div class="pagination-info" role="status" aria-live="polite">
Showing {{ startItem() }} - {{ endItem() }} of {{ totalItems }} items Showing {{ startItem() }} - {{ endItem() }} of {{ totalItems }} items
</div> </div>
<div class="pagination-controls"> <div class="pagination-controls" role="navigation" aria-label="Page navigation">
<button <button
(click)="onPageChange(1)" (click)="onPageChange(1)"
[disabled]="currentPage === 1" [disabled]="currentPage === 1"
class="btn btn-sm pagination-btn" class="btn btn-sm pagination-btn"
title="First page" aria-label="Go to first page"
type="button"
> >
<span aria-hidden="true">⟪</span>
</button> </button>
<button <button
(click)="onPageChange(currentPage - 1)" (click)="onPageChange(currentPage - 1)"
[disabled]="currentPage === 1" [disabled]="currentPage === 1"
class="btn btn-sm pagination-btn" class="btn btn-sm pagination-btn"
title="Previous page" aria-label="Go to previous page"
type="button"
> >
<span aria-hidden="true">←</span>
</button> </button>
<div class="page-numbers"> <div class="page-numbers" role="group" aria-label="Page numbers">
@for (page of pageNumbers(); track page) { @for (page of pageNumbers(); track page) {
@if (page === '...') { @if (page === '...') {
<span class="page-ellipsis">{{ page }}</span> <span class="page-ellipsis" aria-hidden="true">{{ page }}</span>
} @else { } @else {
<button <button
(click)="onPageChange(+page)" (click)="onPageChange(+page)"
[class.active]="currentPage === +page" [class.active]="currentPage === +page"
[attr.aria-current]="currentPage === +page ? 'page' : null"
[attr.aria-label]="'Go to page ' + page"
class="btn btn-sm pagination-btn page-number" class="btn btn-sm pagination-btn page-number"
type="button"
> >
{{ page }} {{ page }}
</button> </button>
@@ -56,18 +61,20 @@ import { CommonModule } from '@angular/common';
(click)="onPageChange(currentPage + 1)" (click)="onPageChange(currentPage + 1)"
[disabled]="currentPage === totalPages()" [disabled]="currentPage === totalPages()"
class="btn btn-sm pagination-btn" class="btn btn-sm pagination-btn"
title="Next page" aria-label="Go to next page"
type="button"
> >
<span aria-hidden="true">→</span>
</button> </button>
<button <button
(click)="onPageChange(totalPages())" (click)="onPageChange(totalPages())"
[disabled]="currentPage === totalPages()" [disabled]="currentPage === totalPages()"
class="btn btn-sm pagination-btn" class="btn btn-sm pagination-btn"
title="Last page" aria-label="Go to last page"
type="button"
> >
<span aria-hidden="true">⟫</span>
</button> </button>
</div> </div>
@@ -78,6 +85,7 @@ import { CommonModule } from '@angular/common';
[value]="pageSize" [value]="pageSize"
(change)="onPageSizeChange($event)" (change)="onPageSizeChange($event)"
class="page-size-select" class="page-size-select"
aria-label="Select number of items per page"
> >
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
@@ -85,7 +93,7 @@ import { CommonModule } from '@angular/common';
<option value="100">100</option> <option value="100">100</option>
</select> </select>
</div> </div>
</div> </nav>
`, `,
styles: [` styles: [`
.pagination-container { .pagination-container {
@@ -79,6 +79,24 @@
transform: scale(1.2); transform: scale(1.2);
} }
.toast-close:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-radius: 4px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
@keyframes slideIn { @keyframes slideIn {
from { from {
transform: translateX(400px); transform: translateX(400px);
@@ -4,17 +4,21 @@
@author Naomi Carrigan @author Naomi Carrigan
--> -->
<div class="toast-container"> <div
class="toast-container"
role="region"
aria-label="Notifications"
aria-live="polite"
aria-atomic="false"
>
@for (toast of toastService.toastList(); track toast.id) { @for (toast of toastService.toastList(); track toast.id) {
<div <div
class="toast toast-{{ toast.type }}" class="toast toast-{{ toast.type }}"
(click)="toastService.remove(toast.id)" [attr.role]="toast.type === 'error' ? 'alert' : 'status'"
(keyup.enter)="toastService.remove(toast.id)" [attr.aria-live]="toast.type === 'error' ? 'assertive' : 'polite'"
(keyup.space)="toastService.remove(toast.id)" aria-atomic="true"
tabindex="0"
role="button"
> >
<div class="toast-icon"> <div class="toast-icon" aria-hidden="true">
@switch (toast.type) { @switch (toast.type) {
@case ('error') { ❌ } @case ('error') { ❌ }
@case ('success') { ✅ } @case ('success') { ✅ }
@@ -22,8 +26,16 @@
@case ('warning') { ⚠️ } @case ('warning') { ⚠️ }
} }
</div> </div>
<div class="toast-message">{{ toast.message }}</div> <div class="toast-message">
<button class="toast-close" (click)="toastService.remove(toast.id)">×</button> <span class="sr-only">{{ getTypeLabel(toast.type) }}:</span>
{{ toast.message }}
</div>
<button
class="toast-close"
(click)="toastService.remove(toast.id)"
[attr.aria-label]="'Dismiss ' + getTypeLabel(toast.type) + ' notification'"
type="button"
>×</button>
</div> </div>
} }
</div> </div>
@@ -15,4 +15,14 @@ import { ToastService } from '../../services/toast.service';
}) })
export class ToastComponent { export class ToastComponent {
public toastService = inject(ToastService); public toastService = inject(ToastService);
getTypeLabel(type: string): string {
switch (type) {
case 'error': return 'Error';
case 'success': return 'Success';
case 'warning': return 'Warning';
case 'info': return 'Information';
default: return 'Notification';
}
}
} }
+73
View File
@@ -142,3 +142,76 @@ button {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--witch-plum); background: var(--witch-plum);
} }
// Skip to main content link
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--witch-rose);
color: var(--witch-moon);
padding: 0.75rem 1.5rem;
text-decoration: none;
font-weight: 600;
border-radius: 0 0 4px 0;
z-index: 10000;
transition: top 0.3s;
}
.skip-link:focus {
top: 0;
}
// Enhanced focus styles for keyboard navigation
*:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-radius: 2px;
}
// Remove default focus outline when focus-visible is used
*:focus:not(:focus-visible) {
outline: none;
}
// Button focus styles
button:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
}
// Link focus styles
a:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
text-decoration: underline;
}
// Form input focus styles
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 3px solid var(--witch-rose);
outline-offset: 2px;
border-color: var(--witch-rose);
}
// Ensure sufficient contrast for focus indicators
@media (prefers-contrast: high) {
*:focus-visible {
outline-width: 4px;
outline-offset: 3px;
}
}
// Reduced motion preferences
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}