Add comprehensive library expansion with new components and demos

- Add new libraries: ui-accessibility, ui-animations, ui-backgrounds, ui-code-display, ui-data-utils, ui-font-manager, hcl-studio
- Add extensive layout components: gallery-grid, infinite-scroll-container, kanban-board, masonry, split-view, sticky-layout
- Add comprehensive demo components for all new features
- Update project configuration and dependencies
- Expand component exports and routing structure
- Add UI landing pages planning document

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
skyai_dev
2025-09-05 05:37:37 +10:00
parent 876eb301a0
commit 5346d6d0c9
5476 changed files with 350855 additions and 10 deletions

View File

@@ -0,0 +1,364 @@
# UI Accessibility
A comprehensive Angular accessibility library that enhances your existing components with WCAG 2.1 Level AA compliant features while using semantic tokens from your design system.
## Features
- **🎯 Focus Management** - Advanced focus trapping, restoration, and monitoring
- **⌨️ Keyboard Navigation** - Arrow keys, roving tabindex, custom shortcuts
- **📢 Screen Reader Support** - Live announcements, ARIA enhancements
- **🎨 Design System Integration** - Uses semantic motion, color, and spacing tokens
- **♿ High Contrast & Reduced Motion** - Automatic detection and adaptation
- **🔧 Zero Rewrites Required** - Enhance existing components with directives
- **🧪 Developer Experience** - Debug modes, warnings, and comprehensive logging
## Installation
```bash
npm install ui-accessibility
```
## Quick Start
### 1. Import the Module
```typescript
import { UiAccessibilityModule } from 'ui-accessibility';
@NgModule({
imports: [
UiAccessibilityModule.forRoot({
// Optional configuration
skipLinks: { enabled: true },
keyboard: { enableArrowNavigation: true },
development: { warnings: true }
})
]
})
export class AppModule {}
```
### 2. Import SCSS Utilities
```scss
// Import accessibility utilities (uses your semantic tokens)
@use 'ui-accessibility/src/lib/utilities/a11y-utilities';
```
### 3. Add Skip Links
```html
<!-- Add to your app.component.html -->
<ui-skip-links></ui-skip-links>
```
## Core Features
### Focus Trap Directive
Trap focus within containers like modals and drawers:
```html
<!-- Before: Basic modal -->
<ui-modal [open]="isOpen">
<h2>Settings</h2>
<input type="text" placeholder="Name">
<button>Save</button>
</ui-modal>
<!-- After: Focus-trapped modal -->
<ui-modal [open]="isOpen" uiFocusTrap>
<h2>Settings</h2>
<input type="text" placeholder="Name">
<button>Save</button>
</ui-modal>
```
**Features:**
- Automatic focus on first element
- Tab wrapping within container
- Focus restoration on close
- Uses semantic motion tokens for transitions
### Arrow Navigation Directive
Add keyboard navigation to lists, menus, and grids:
```html
<!-- Before: Basic menu -->
<ui-menu>
<ui-menu-item>Profile</ui-menu-item>
<ui-menu-item>Settings</ui-menu-item>
<ui-menu-item>Logout</ui-menu-item>
</ui-menu>
<!-- After: Keyboard navigable menu -->
<ui-menu uiArrowNavigation="vertical">
<ui-menu-item tabindex="0">Profile</ui-menu-item>
<ui-menu-item tabindex="-1">Settings</ui-menu-item>
<ui-menu-item tabindex="-1">Logout</ui-menu-item>
</ui-menu>
```
**Navigation Options:**
- `vertical` - Up/Down arrows
- `horizontal` - Left/Right arrows
- `both` - All arrow keys
- `grid` - 2D navigation with column support
### Live Announcer Service
Announce dynamic content changes to screen readers:
```typescript
import { LiveAnnouncerService } from 'ui-accessibility';
constructor(private announcer: LiveAnnouncerService) {}
// Announce with different politeness levels
onItemAdded() {
this.announcer.announce('Item added to cart', 'polite');
}
onError() {
this.announcer.announce('Error: Please fix the required fields', 'assertive');
}
// Announce a sequence of messages
onProcessComplete() {
this.announcer.announceSequence([
'Processing started',
'Validating data',
'Processing complete'
], 'polite', 1500);
}
```
### Screen Reader Components
Hide content visually while keeping it accessible:
```html
<!-- Screen reader only text -->
<ui-screen-reader-only>
Additional context for screen readers
</ui-screen-reader-only>
<!-- Focusable hidden text (becomes visible on focus) -->
<ui-screen-reader-only type="focusable">
Press Enter to activate
</ui-screen-reader-only>
<!-- Status messages -->
<ui-screen-reader-only type="status" statusType="error" [visible]="hasError">
Please correct the errors below
</ui-screen-reader-only>
```
### Keyboard Manager Service
Register global and element-specific keyboard shortcuts:
```typescript
import { KeyboardManagerService } from 'ui-accessibility';
constructor(private keyboard: KeyboardManagerService) {}
ngOnInit() {
// Global shortcut
this.keyboard.registerGlobalShortcut('save', {
key: 's',
ctrlKey: true,
description: 'Save document',
handler: () => this.save()
});
// Element-specific shortcut
const element = this.elementRef.nativeElement;
this.keyboard.registerElementShortcut('close', element, {
key: 'Escape',
description: 'Close dialog',
handler: () => this.close()
});
}
```
## Design System Integration
All features use semantic tokens from your design system:
### Focus Indicators
```scss
// Automatic integration with your focus tokens
.my-component:focus-visible {
outline: $semantic-border-focus-width solid $semantic-color-focus;
box-shadow: $semantic-shadow-input-focus;
transition: outline-color $semantic-motion-duration-fast;
}
```
### Skip Links Styling
Uses your semantic spacing, colors, and typography:
```scss
.skip-link {
padding: $semantic-spacing-component-padding-y $semantic-spacing-component-padding-x;
background: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
border-color: $semantic-color-border-focus;
}
```
### Motion & Animations
Respects reduced motion preferences:
```typescript
// Service automatically detects preference
const duration = this.highContrast.getMotionDuration('300ms', '0.01s');
// SCSS utilities adapt automatically
@media (prefers-reduced-motion: reduce) {
.fade-in-a11y {
animation-duration: 0.01s;
}
}
```
## Advanced Configuration
### Full Configuration Example
```typescript
UiAccessibilityModule.forRoot({
announcer: {
defaultPoliteness: 'polite',
defaultDuration: 4000,
enabled: true
},
focusManagement: {
trapFocus: true,
restoreFocus: true,
focusVisibleEnabled: true
},
keyboard: {
enableShortcuts: true,
enableArrowNavigation: true,
customShortcuts: [
{ key: '/', ctrlKey: true, description: 'Search' }
]
},
skipLinks: {
enabled: true,
position: 'top',
links: [
{ href: '#main', text: 'Skip to main content' },
{ href: '#nav', text: 'Skip to navigation' },
{ href: '#search', text: 'Skip to search' }
]
},
accessibility: {
respectReducedMotion: true,
respectHighContrast: true,
injectAccessibilityStyles: true,
addBodyClasses: true
},
development: {
warnings: true,
logging: true
}
})
```
### High Contrast & Reduced Motion
Automatic detection and CSS class application:
```html
<!-- Body classes added automatically -->
<body class="prefers-reduced-motion prefers-high-contrast">
<!-- Your CSS can respond -->
<style>
.prefers-reduced-motion * {
animation-duration: 0.01s !important;
}
.prefers-high-contrast .card {
border: 1px solid ButtonText;
background: ButtonFace;
}
</style>
```
## Available Directives
| Directive | Purpose | Usage |
|-----------|---------|--------|
| `uiFocusTrap` | Trap focus within container | `<div uiFocusTrap>` |
| `uiArrowNavigation` | Arrow key navigation | `<ul uiArrowNavigation="vertical">` |
## Available Services
| Service | Purpose | Key Methods |
|---------|---------|-------------|
| `LiveAnnouncerService` | Screen reader announcements | `announce()`, `announceSequence()` |
| `FocusMonitorService` | Focus origin tracking | `monitor()`, `focusVia()` |
| `KeyboardManagerService` | Keyboard shortcuts | `registerGlobalShortcut()` |
| `HighContrastService` | Accessibility preferences | `getCurrentPreferences()` |
| `A11yConfigService` | Configuration management | `getConfig()`, `isEnabled()` |
## SCSS Utilities
```scss
// Import specific utilities
@use 'ui-accessibility/src/lib/utilities/focus-visible';
@use 'ui-accessibility/src/lib/utilities/screen-reader';
// Use utility classes
.my-element {
@extend .focus-ring; // Adds focus ring on :focus-visible
@extend .touch-target; // Ensures minimum touch target size
}
// Screen reader utilities
.sr-instructions {
@extend .sr-only; // Visually hidden, screen reader accessible
}
.status-text {
@extend .sr-only-focusable; // Hidden until focused
}
```
## WCAG 2.1 Compliance
This library helps achieve Level AA compliance:
-**1.3.1** - Info and Relationships (ARIA attributes)
-**1.4.3** - Contrast (High contrast mode support)
-**1.4.13** - Content on Hover/Focus (Proper focus management)
-**2.1.1** - Keyboard (Full keyboard navigation)
-**2.1.2** - No Keyboard Trap (Proper focus trapping)
-**2.4.1** - Bypass Blocks (Skip links)
-**2.4.3** - Focus Order (Logical tab sequence)
-**2.4.7** - Focus Visible (Enhanced focus indicators)
-**3.2.1** - On Focus (Predictable focus behavior)
-**4.1.3** - Status Messages (Live announcements)
## Browser Support
- Chrome 88+
- Firefox 85+
- Safari 14+
- Edge 88+
## Demo
Visit `/accessibility` in your demo application to see interactive examples of all features.
## Contributing
This library integrates seamlessly with your existing component architecture and design system tokens, requiring no rewrites of existing components.

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/ui-accessibility",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "ui-accessibility",
"version": "0.0.1",
"description": "Comprehensive accessibility utilities for Angular applications with semantic token integration",
"keywords": ["angular", "accessibility", "a11y", "wcag", "focus-management", "screen-reader", "semantic-tokens"],
"peerDependencies": {
"@angular/common": "^19.2.0",
"@angular/core": "^19.2.0",
"rxjs": "~7.8.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}

View File

@@ -0,0 +1 @@
export * from './screen-reader-only.component';

View File

@@ -0,0 +1,167 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'ui-screen-reader-only',
standalone: true,
imports: [CommonModule],
template: `
<span
[class]="getClasses()"
[attr.aria-live]="ariaLive"
[attr.aria-atomic]="ariaAtomic"
[attr.role]="role"
>
<ng-content></ng-content>
</span>
`,
styles: [`
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only-focusable {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only-focusable:focus,
.sr-only-focusable:active {
position: static;
width: auto;
height: auto;
padding: 0.5rem 1rem;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
background: var(--a11y-skip-link-bg, #fff);
color: var(--a11y-skip-link-color, #000);
border: 1px solid var(--a11y-skip-link-border, #007bff);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10000;
}
.status-message {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.status-message.status-visible {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
padding: 0.5rem 1rem;
margin: 4px 0;
background: var(--a11y-skip-link-bg, #fff);
border-left: 4px solid var(--a11y-focus-color, #007bff);
border-radius: 0 4px 4px 0;
}
.status-message.status-error {
border-left-color: var(--a11y-error-color, #dc3545);
}
.status-message.status-success {
border-left-color: var(--a11y-success-color, #28a745);
}
.status-message.status-warning {
border-left-color: var(--a11y-warning-color, #ffc107);
}
.instructions {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-contrast: high) {
.sr-only-focusable:focus,
.sr-only-focusable:active {
background: ButtonFace;
color: ButtonText;
border-color: ButtonText;
}
.status-message.status-visible {
background: ButtonFace;
color: ButtonText;
border-color: ButtonText;
}
}
`]
})
export class ScreenReaderOnlyComponent {
@Input() type: 'default' | 'focusable' | 'status' | 'instructions' = 'default';
@Input() statusType?: 'error' | 'success' | 'warning';
@Input() visible = false;
@Input() ariaLive?: 'polite' | 'assertive' | 'off';
@Input() ariaAtomic?: 'true' | 'false';
@Input() role?: string;
/**
* Get CSS classes based on component configuration
*/
getClasses(): string {
const classes: string[] = [];
switch (this.type) {
case 'focusable':
classes.push('sr-only-focusable');
break;
case 'status':
classes.push('status-message');
if (this.visible) {
classes.push('status-visible');
}
if (this.statusType) {
classes.push(`status-${this.statusType}`);
}
break;
case 'instructions':
classes.push('instructions');
break;
default:
classes.push('sr-only');
break;
}
return classes.join(' ');
}
}

View File

@@ -0,0 +1 @@
export * from './skip-links.component';

View File

@@ -0,0 +1,203 @@
import { Component, Input, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { A11yConfigService } from '../../services/a11y-config';
export interface SkipLink {
href: string;
text: string;
ariaLabel?: string;
}
@Component({
selector: 'ui-skip-links',
standalone: true,
imports: [CommonModule],
template: `
<nav
class="skip-links"
[class.skip-links--after-header]="position === 'after-header'"
role="navigation"
aria-label="Skip navigation links"
>
@for (link of skipLinks; track link.href) {
<a
class="skip-link"
[href]="link.href"
[attr.aria-label]="link.ariaLabel || link.text"
(click)="handleSkipLinkClick($event, link.href)"
>
{{ link.text }}
</a>
}
</nav>
`,
styles: [`
.skip-links {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
}
.skip-links--after-header {
position: relative;
display: block;
background: var(--a11y-skip-link-bg, #fff);
border-bottom: 1px solid var(--a11y-skip-link-border, #ccc);
padding: 0.5rem;
}
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: var(--a11y-skip-link-bg, #fff);
color: var(--a11y-skip-link-color, #000);
padding: 0.5rem 1rem;
text-decoration: none;
border: 1px solid var(--a11y-skip-link-border, #007bff);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
opacity: 0;
pointer-events: none;
transform: translateY(-10px);
transition: all 0.15s ease-out;
font-weight: 500;
white-space: nowrap;
}
.skip-link:focus {
top: 6px;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
outline: 2px solid var(--a11y-focus-color, #007bff);
outline-offset: 2px;
}
.skip-link:nth-child(2):focus {
top: 50px;
}
.skip-link:nth-child(3):focus {
top: 94px;
}
.skip-link:nth-child(4):focus {
top: 138px;
}
.skip-links--after-header .skip-link {
position: static;
display: inline-block;
margin-right: 1rem;
opacity: 1;
pointer-events: auto;
transform: none;
transition: background-color 0.15s ease-out;
}
.skip-links--after-header .skip-link:hover,
.skip-links--after-header .skip-link:focus {
background-color: var(--a11y-focus-ring-color, rgba(0, 123, 255, 0.1));
}
@media (prefers-contrast: high) {
.skip-link {
background: ButtonFace;
color: ButtonText;
border-color: ButtonText;
outline-color: Highlight;
}
}
@media (prefers-reduced-motion: reduce) {
.skip-link {
transition-duration: 0.01s;
}
}
`]
})
export class SkipLinksComponent implements OnInit {
private config = inject(A11yConfigService);
@Input() skipLinks: SkipLink[] = [];
@Input() position: 'top' | 'after-header' = 'top';
@Input() showOnFocus = true;
ngOnInit(): void {
// Use default links from config if none provided
if (this.skipLinks.length === 0) {
const configLinks = this.config.getSection('skipLinks').links;
if (configLinks) {
this.skipLinks = configLinks.map(link => ({
href: link.href,
text: link.text,
ariaLabel: `Skip to ${link.text.toLowerCase()}`
}));
}
}
// Use position from config if not explicitly set
if (this.position === 'top') {
const configPosition = this.config.getSection('skipLinks').position;
if (configPosition) {
this.position = configPosition;
}
}
}
/**
* Handle skip link click to ensure proper focus management
*/
handleSkipLinkClick(event: Event, href: string): void {
event.preventDefault();
const targetId = href.replace('#', '');
const targetElement = document.getElementById(targetId);
if (targetElement) {
// Set tabindex to make element focusable if it's not naturally focusable
const originalTabIndex = targetElement.getAttribute('tabindex');
if (!this.isFocusable(targetElement)) {
targetElement.setAttribute('tabindex', '-1');
}
// Focus the target element
targetElement.focus();
// Scroll to the element
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Restore original tabindex after focus
if (originalTabIndex === null && !this.isFocusable(targetElement)) {
setTimeout(() => {
targetElement.removeAttribute('tabindex');
}, 100);
}
this.config.log('Skip link activated', { href, targetId });
} else {
this.config.warn('Skip link target not found', { href, targetId });
}
}
/**
* Check if an element is naturally focusable
*/
private isFocusable(element: HTMLElement): boolean {
const focusableSelectors = [
'a[href]',
'button',
'textarea',
'input[type="text"]',
'input[type="radio"]',
'input[type="checkbox"]',
'select',
'[tabindex]:not([tabindex="-1"])'
];
return focusableSelectors.some(selector => element.matches(selector)) ||
element.getAttribute('contenteditable') === 'true';
}
}

View File

@@ -0,0 +1,382 @@
import {
Directive,
ElementRef,
Input,
OnDestroy,
OnInit,
Output,
EventEmitter,
NgZone,
inject
} from '@angular/core';
import { fromEvent, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { A11yConfigService } from '../../services/a11y-config';
export type NavigationDirection = 'horizontal' | 'vertical' | 'both' | 'grid';
export interface NavigationEvent {
direction: 'up' | 'down' | 'left' | 'right' | 'home' | 'end';
currentIndex: number;
nextIndex: number;
event: KeyboardEvent;
}
@Directive({
selector: '[uiArrowNavigation]',
standalone: true,
exportAs: 'uiArrowNavigation'
})
export class ArrowNavigationDirective implements OnInit, OnDestroy {
private elementRef = inject(ElementRef);
private ngZone = inject(NgZone);
private config = inject(A11yConfigService);
@Input('uiArrowNavigation') direction: NavigationDirection = 'vertical';
@Input() wrap = true;
@Input() skipDisabled = true;
@Input() itemSelector = '[tabindex]:not([tabindex="-1"]), button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
@Input() gridColumns?: number;
@Output() navigationChange = new EventEmitter<NavigationEvent>();
private destroy$ = new Subject<void>();
private navigableItems: HTMLElement[] = [];
private currentIndex = 0;
private isGridNavigation = false;
ngOnInit(): void {
if (this.config.isEnabled('keyboard.enableArrowNavigation')) {
this.setupNavigation();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Navigate to a specific index
*/
navigateToIndex(index: number): void {
this.updateNavigableItems();
if (index >= 0 && index < this.navigableItems.length) {
this.currentIndex = index;
this.focusCurrentItem();
}
}
/**
* Navigate to the first item
*/
navigateToFirst(): void {
this.navigateToIndex(0);
}
/**
* Navigate to the last item
*/
navigateToLast(): void {
this.updateNavigableItems();
this.navigateToIndex(this.navigableItems.length - 1);
}
/**
* Get the current navigation index
*/
getCurrentIndex(): number {
return this.currentIndex;
}
/**
* Get the total number of navigable items
*/
getItemCount(): number {
this.updateNavigableItems();
return this.navigableItems.length;
}
/**
* Set up navigation listeners
*/
private setupNavigation(): void {
if (typeof document === 'undefined') return;
this.isGridNavigation = this.direction === 'grid';
this.updateNavigableItems();
this.setupKeyboardListeners();
this.setupFocusTracking();
}
/**
* Update the list of navigable items
*/
private updateNavigableItems(): void {
const container = this.elementRef.nativeElement;
const items = Array.from(
container.querySelectorAll(this.itemSelector)
) as HTMLElement[];
this.navigableItems = this.skipDisabled
? items.filter((item: HTMLElement) => !this.isDisabled(item))
: items;
// Update current index if current item is no longer available
if (this.currentIndex >= this.navigableItems.length) {
this.currentIndex = Math.max(0, this.navigableItems.length - 1);
}
}
/**
* Set up keyboard event listeners
*/
private setupKeyboardListeners(): void {
this.ngZone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(this.elementRef.nativeElement, 'keydown').pipe(
takeUntil(this.destroy$)
).subscribe(event => {
this.handleKeyDown(event);
});
// Update items when DOM changes
const observer = new MutationObserver(() => {
this.updateNavigableItems();
});
observer.observe(this.elementRef.nativeElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['disabled', 'tabindex', 'hidden']
});
this.destroy$.subscribe(() => observer.disconnect());
});
}
/**
* Set up focus tracking to maintain current index
*/
private setupFocusTracking(): void {
this.ngZone.runOutsideAngular(() => {
fromEvent(this.elementRef.nativeElement, 'focusin').pipe(
takeUntil(this.destroy$)
).subscribe((event) => {
const focusEvent = event as FocusEvent;
const target = focusEvent.target as HTMLElement;
const index = this.navigableItems.indexOf(target);
if (index >= 0) {
this.currentIndex = index;
}
});
});
}
/**
* Handle keyboard navigation
*/
private handleKeyDown(event: KeyboardEvent): void {
if (this.navigableItems.length === 0) return;
const { key } = event;
let nextIndex = this.currentIndex;
let direction: NavigationEvent['direction'] | null = null;
switch (key) {
case 'ArrowUp':
direction = 'up';
nextIndex = this.isGridNavigation
? this.getGridIndex('up')
: this.getVerticalIndex('up');
break;
case 'ArrowDown':
direction = 'down';
nextIndex = this.isGridNavigation
? this.getGridIndex('down')
: this.getVerticalIndex('down');
break;
case 'ArrowLeft':
direction = 'left';
nextIndex = this.isGridNavigation
? this.getGridIndex('left')
: this.getHorizontalIndex('left');
break;
case 'ArrowRight':
direction = 'right';
nextIndex = this.isGridNavigation
? this.getGridIndex('right')
: this.getHorizontalIndex('right');
break;
case 'Home':
direction = 'home';
nextIndex = 0;
break;
case 'End':
direction = 'end';
nextIndex = this.navigableItems.length - 1;
break;
default:
return; // Don't handle other keys
}
if (direction && this.shouldHandleNavigation(key)) {
event.preventDefault();
event.stopPropagation();
const navigationEvent: NavigationEvent = {
direction,
currentIndex: this.currentIndex,
nextIndex,
event
};
this.ngZone.run(() => {
this.navigationChange.emit(navigationEvent);
});
if (nextIndex !== this.currentIndex && nextIndex >= 0 && nextIndex < this.navigableItems.length) {
this.currentIndex = nextIndex;
this.focusCurrentItem();
}
}
}
/**
* Check if we should handle this navigation key
*/
private shouldHandleNavigation(key: string): boolean {
switch (this.direction) {
case 'horizontal':
return ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key);
case 'vertical':
return ['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key);
case 'both':
case 'grid':
return ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key);
default:
return false;
}
}
/**
* Get next index for vertical navigation
*/
private getVerticalIndex(direction: 'up' | 'down'): number {
const delta = direction === 'up' ? -1 : 1;
let nextIndex = this.currentIndex + delta;
if (this.wrap) {
if (nextIndex < 0) {
nextIndex = this.navigableItems.length - 1;
} else if (nextIndex >= this.navigableItems.length) {
nextIndex = 0;
}
} else {
nextIndex = Math.max(0, Math.min(this.navigableItems.length - 1, nextIndex));
}
return nextIndex;
}
/**
* Get next index for horizontal navigation
*/
private getHorizontalIndex(direction: 'left' | 'right'): number {
const delta = direction === 'left' ? -1 : 1;
let nextIndex = this.currentIndex + delta;
if (this.wrap) {
if (nextIndex < 0) {
nextIndex = this.navigableItems.length - 1;
} else if (nextIndex >= this.navigableItems.length) {
nextIndex = 0;
}
} else {
nextIndex = Math.max(0, Math.min(this.navigableItems.length - 1, nextIndex));
}
return nextIndex;
}
/**
* Get next index for grid navigation
*/
private getGridIndex(direction: 'up' | 'down' | 'left' | 'right'): number {
if (!this.gridColumns) {
// Fallback to regular navigation if no columns specified
return direction === 'up' || direction === 'down'
? this.getVerticalIndex(direction as 'up' | 'down')
: this.getHorizontalIndex(direction as 'left' | 'right');
}
const currentRow = Math.floor(this.currentIndex / this.gridColumns);
const currentCol = this.currentIndex % this.gridColumns;
let nextIndex = this.currentIndex;
switch (direction) {
case 'up':
if (currentRow > 0) {
nextIndex = (currentRow - 1) * this.gridColumns + currentCol;
} else if (this.wrap) {
const lastRow = Math.floor((this.navigableItems.length - 1) / this.gridColumns);
nextIndex = Math.min(lastRow * this.gridColumns + currentCol, this.navigableItems.length - 1);
}
break;
case 'down':
const nextRow = currentRow + 1;
const nextRowIndex = nextRow * this.gridColumns + currentCol;
if (nextRowIndex < this.navigableItems.length) {
nextIndex = nextRowIndex;
} else if (this.wrap) {
nextIndex = currentCol < this.navigableItems.length ? currentCol : 0;
}
break;
case 'left':
if (currentCol > 0) {
nextIndex = this.currentIndex - 1;
} else if (this.wrap) {
const lastInRow = Math.min((currentRow + 1) * this.gridColumns - 1, this.navigableItems.length - 1);
nextIndex = lastInRow;
}
break;
case 'right':
if (currentCol < this.gridColumns - 1 && this.currentIndex + 1 < this.navigableItems.length) {
nextIndex = this.currentIndex + 1;
} else if (this.wrap) {
nextIndex = currentRow * this.gridColumns;
}
break;
}
return nextIndex;
}
/**
* Focus the current item
*/
private focusCurrentItem(): void {
if (this.navigableItems[this.currentIndex]) {
this.navigableItems[this.currentIndex].focus();
}
}
/**
* Check if an item is disabled
*/
private isDisabled(item: HTMLElement): boolean {
return item.hasAttribute('disabled') ||
item.getAttribute('aria-disabled') === 'true' ||
item.tabIndex === -1;
}
}

View File

@@ -0,0 +1 @@
export * from './arrow-navigation.directive';

View File

@@ -0,0 +1,210 @@
import {
Directive,
ElementRef,
Input,
OnDestroy,
OnInit,
NgZone,
inject
} from '@angular/core';
import { fromEvent, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { A11yConfigService } from '../../services/a11y-config';
@Directive({
selector: '[uiFocusTrap]',
standalone: true,
exportAs: 'uiFocusTrap'
})
export class FocusTrapDirective implements OnInit, OnDestroy {
private elementRef = inject(ElementRef);
private ngZone = inject(NgZone);
private config = inject(A11yConfigService);
@Input('uiFocusTrap') enabled = true;
@Input() autoFocus = true;
@Input() restoreFocus = true;
private destroy$ = new Subject<void>();
private previouslyFocusedElement: HTMLElement | null = null;
private focusableElements: HTMLElement[] = [];
private firstFocusable: HTMLElement | null = null;
private lastFocusable: HTMLElement | null = null;
ngOnInit(): void {
if (this.enabled) {
this.setupFocusTrap();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
if (this.restoreFocus && this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
}
}
/**
* Enable the focus trap
*/
enable(): void {
this.enabled = true;
this.setupFocusTrap();
}
/**
* Disable the focus trap
*/
disable(): void {
this.enabled = false;
this.destroy$.next();
if (this.restoreFocus && this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
}
}
/**
* Manually focus the first focusable element
*/
focusFirst(): void {
this.updateFocusableElements();
if (this.firstFocusable) {
this.firstFocusable.focus();
}
}
/**
* Manually focus the last focusable element
*/
focusLast(): void {
this.updateFocusableElements();
if (this.lastFocusable) {
this.lastFocusable.focus();
}
}
/**
* Set up the focus trap
*/
private setupFocusTrap(): void {
if (!this.enabled || typeof document === 'undefined') return;
// Store the currently focused element to restore later
this.previouslyFocusedElement = document.activeElement as HTMLElement;
// Set up the trap
setTimeout(() => {
this.updateFocusableElements();
this.setupKeyboardListeners();
if (this.autoFocus) {
this.focusFirst();
}
}, 0);
}
/**
* Update the list of focusable elements
*/
private updateFocusableElements(): void {
const container = this.elementRef.nativeElement;
const focusableSelectors = [
'a[href]:not([disabled])',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"]):not([disabled])',
'[contenteditable="true"]:not([disabled])',
'audio[controls]:not([disabled])',
'video[controls]:not([disabled])',
'iframe:not([disabled])',
'object:not([disabled])',
'embed:not([disabled])',
'area[href]:not([disabled])',
'summary:not([disabled])'
].join(', ');
const elements = container.querySelectorAll(focusableSelectors);
this.focusableElements = Array.from(elements)
.filter((element) => {
const htmlElement = element as HTMLElement;
// Filter out hidden elements
return (
htmlElement.offsetWidth > 0 ||
htmlElement.offsetHeight > 0 ||
htmlElement.getClientRects().length > 0
);
}) as HTMLElement[];
this.firstFocusable = this.focusableElements[0] || null;
this.lastFocusable = this.focusableElements[this.focusableElements.length - 1] || null;
}
/**
* Set up keyboard event listeners
*/
private setupKeyboardListeners(): void {
this.ngZone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(document, 'keydown').pipe(
takeUntil(this.destroy$)
).subscribe(event => {
if (event.key === 'Tab') {
this.handleTabKey(event);
}
});
// Update focusable elements when DOM changes
const observer = new MutationObserver(() => {
this.updateFocusableElements();
});
observer.observe(this.elementRef.nativeElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['tabindex', 'disabled', 'hidden']
});
// Clean up observer
this.destroy$.subscribe(() => observer.disconnect());
});
}
/**
* Handle Tab key press
*/
private handleTabKey(event: KeyboardEvent): void {
if (!this.enabled || this.focusableElements.length === 0) return;
// Check if focus is within our trap
const activeElement = document.activeElement as HTMLElement;
const isWithinTrap = this.elementRef.nativeElement.contains(activeElement);
if (!isWithinTrap) {
// Focus escaped the trap, bring it back
event.preventDefault();
this.focusFirst();
return;
}
// Handle normal tab navigation within trap
if (event.shiftKey) {
// Shift + Tab (backward)
if (activeElement === this.firstFocusable) {
event.preventDefault();
this.focusLast();
}
} else {
// Tab (forward)
if (activeElement === this.lastFocusable) {
event.preventDefault();
this.focusFirst();
}
}
}
}

View File

@@ -0,0 +1 @@
export * from './focus-trap.directive';

View File

@@ -0,0 +1,237 @@
import { Injectable, InjectionToken } from '@angular/core';
export interface A11yConfiguration {
// Live announcer settings
announcer?: {
defaultPoliteness?: 'polite' | 'assertive';
defaultDuration?: number;
enabled?: boolean;
};
// Focus management settings
focusManagement?: {
trapFocus?: boolean;
restoreFocus?: boolean;
focusVisibleEnabled?: boolean;
focusOriginDetection?: boolean;
};
// Keyboard navigation settings
keyboard?: {
enableShortcuts?: boolean;
enableArrowNavigation?: boolean;
enableRovingTabindex?: boolean;
customShortcuts?: Array<{
key: string;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
description?: string;
}>;
};
// Skip links settings
skipLinks?: {
enabled?: boolean;
position?: 'top' | 'after-header';
showOnFocus?: boolean;
links?: Array<{
href: string;
text: string;
}>;
};
// Auto-enhancement settings
autoEnhancement?: {
enabled?: boolean;
enhanceComponents?: string[]; // Component selectors to enhance
addAriaAttributes?: boolean;
addKeyboardSupport?: boolean;
addFocusManagement?: boolean;
};
// High contrast and reduced motion
accessibility?: {
respectReducedMotion?: boolean;
respectHighContrast?: boolean;
injectAccessibilityStyles?: boolean;
addBodyClasses?: boolean;
};
// Development settings
development?: {
warnings?: boolean;
logging?: boolean;
accessibilityAuditing?: boolean;
};
}
export const DEFAULT_A11Y_CONFIG: Required<A11yConfiguration> = {
announcer: {
defaultPoliteness: 'polite',
defaultDuration: 4000,
enabled: true
},
focusManagement: {
trapFocus: true,
restoreFocus: true,
focusVisibleEnabled: true,
focusOriginDetection: true
},
keyboard: {
enableShortcuts: true,
enableArrowNavigation: true,
enableRovingTabindex: true,
customShortcuts: []
},
skipLinks: {
enabled: true,
position: 'top',
showOnFocus: true,
links: [
{ href: '#main', text: 'Skip to main content' },
{ href: '#navigation', text: 'Skip to navigation' }
]
},
autoEnhancement: {
enabled: false,
enhanceComponents: [],
addAriaAttributes: true,
addKeyboardSupport: true,
addFocusManagement: true
},
accessibility: {
respectReducedMotion: true,
respectHighContrast: true,
injectAccessibilityStyles: true,
addBodyClasses: true
},
development: {
warnings: true,
logging: false,
accessibilityAuditing: false
}
};
export const A11Y_CONFIG = new InjectionToken<A11yConfiguration>('A11Y_CONFIG');
@Injectable({
providedIn: 'root'
})
export class A11yConfigService {
private config: Required<A11yConfiguration>;
constructor() {
this.config = { ...DEFAULT_A11Y_CONFIG };
}
/**
* Update the configuration
*/
updateConfig(config: Partial<A11yConfiguration>): void {
this.config = this.mergeConfig(this.config, config);
}
/**
* Get the current configuration
*/
getConfig(): Required<A11yConfiguration> {
return { ...this.config };
}
/**
* Get a specific configuration section
*/
getSection<K extends keyof A11yConfiguration>(section: K): Required<A11yConfiguration>[K] {
return this.config[section];
}
/**
* Check if a feature is enabled
*/
isEnabled(feature: string): boolean {
const parts = feature.split('.');
let current: any = this.config;
for (const part of parts) {
if (current && typeof current === 'object' && part in current) {
current = current[part];
} else {
return false;
}
}
return current === true;
}
/**
* Log a message if logging is enabled
*/
log(message: string, ...args: any[]): void {
if (this.config.development.logging) {
console.log(`[A11y]`, message, ...args);
}
}
/**
* Log a warning if warnings are enabled
*/
warn(message: string, ...args: any[]): void {
if (this.config.development.warnings) {
console.warn(`[A11y Warning]`, message, ...args);
}
}
/**
* Log an error
*/
error(message: string, ...args: any[]): void {
console.error(`[A11y Error]`, message, ...args);
}
/**
* Deep merge configuration objects
*/
private mergeConfig(
base: Required<A11yConfiguration>,
override: Partial<A11yConfiguration>
): Required<A11yConfiguration> {
const result = { ...base };
for (const key in override) {
const value = override[key as keyof A11yConfiguration];
if (value !== undefined) {
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
(result as any)[key] = this.mergeObjects(
(result as any)[key] || {},
value
);
} else {
(result as any)[key] = value;
}
}
}
return result;
}
/**
* Deep merge objects
*/
private mergeObjects(base: any, override: any): any {
const result = { ...base };
for (const key in override) {
const value = override[key];
if (value !== undefined) {
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
result[key] = this.mergeObjects(result[key] || {}, value);
} else {
result[key] = value;
}
}
}
return result;
}
}

View File

@@ -0,0 +1 @@
export * from './a11y-config.service';

View File

@@ -0,0 +1,202 @@
import { Injectable, NgZone, OnDestroy, ElementRef } from '@angular/core';
import { Observable, Subject, fromEvent, merge } from 'rxjs';
import { takeUntil, map, distinctUntilChanged } from 'rxjs/operators';
export type FocusOrigin = 'keyboard' | 'mouse' | 'touch' | 'program' | null;
export interface FocusMonitorOptions {
detectSubtleFocusChanges?: boolean;
checkChildren?: boolean;
}
export interface FocusEvent {
origin: FocusOrigin;
event: Event | null;
}
@Injectable({
providedIn: 'root'
})
export class FocusMonitorService implements OnDestroy {
private destroy$ = new Subject<void>();
private currentFocusOrigin: FocusOrigin = null;
private lastFocusOrigin: FocusOrigin = null;
private lastTouchTarget: EventTarget | null = null;
private monitoredElements = new Map<Element, {
subject: Subject<FocusEvent>;
unlisten: () => void;
}>();
constructor(private ngZone: NgZone) {
this.setupGlobalListeners();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Clean up all monitored elements
this.monitoredElements.forEach((info) => info.unlisten());
this.monitoredElements.clear();
}
/**
* Monitor focus changes on an element
*/
monitor(
element: Element | ElementRef<Element>,
options: FocusMonitorOptions = {}
): Observable<FocusEvent> {
const elementToMonitor = element instanceof ElementRef ? element.nativeElement : element;
if (this.monitoredElements.has(elementToMonitor)) {
return this.monitoredElements.get(elementToMonitor)!.subject.asObservable();
}
const subject = new Subject<FocusEvent>();
const focusHandler = (event: FocusEvent) => {
subject.next(event);
};
const blurHandler = () => {
subject.next({ origin: null, event: null });
};
// Set up event listeners
const focusListener = this.ngZone.runOutsideAngular(() => {
return fromEvent(elementToMonitor, 'focus', { capture: true }).pipe(
takeUntil(this.destroy$),
map((event: Event) => ({
origin: this.currentFocusOrigin,
event
}))
).subscribe(focusHandler);
});
const blurListener = this.ngZone.runOutsideAngular(() => {
return fromEvent(elementToMonitor, 'blur', { capture: true }).pipe(
takeUntil(this.destroy$)
).subscribe(blurHandler);
});
const unlisten = () => {
focusListener.unsubscribe();
blurListener.unsubscribe();
subject.complete();
};
this.monitoredElements.set(elementToMonitor, { subject, unlisten });
return subject.asObservable().pipe(
distinctUntilChanged((x, y) => x.origin === y.origin)
);
}
/**
* Stop monitoring focus changes on an element
*/
stopMonitoring(element: Element | ElementRef<Element>): void {
const elementToStop = element instanceof ElementRef ? element.nativeElement : element;
const info = this.monitoredElements.get(elementToStop);
if (info) {
info.unlisten();
this.monitoredElements.delete(elementToStop);
}
}
/**
* Focus an element and mark it as programmatically focused
*/
focusVia(element: Element | ElementRef<Element>, origin: FocusOrigin): void {
const elementToFocus = element instanceof ElementRef ? element.nativeElement : element;
this.setOrigin(origin);
if (typeof (elementToFocus as any).focus === 'function') {
(elementToFocus as HTMLElement).focus();
}
}
/**
* Get the current focus origin
*/
getCurrentFocusOrigin(): FocusOrigin {
return this.currentFocusOrigin;
}
/**
* Set the focus origin for the next focus event
*/
private setOrigin(origin: FocusOrigin): void {
this.currentFocusOrigin = origin;
this.lastFocusOrigin = origin;
}
/**
* Set up global event listeners to detect focus origin
*/
private setupGlobalListeners(): void {
if (typeof document === 'undefined') return;
this.ngZone.runOutsideAngular(() => {
// Mouse events
merge(
fromEvent(document, 'mousedown', { capture: true }),
fromEvent(document, 'mouseup', { capture: true })
).pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.setOrigin('mouse');
});
// Keyboard events
fromEvent(document, 'keydown', { capture: true }).pipe(
takeUntil(this.destroy$)
).subscribe((event) => {
const keyEvent = event as KeyboardEvent;
// Only set keyboard origin for navigation keys
if (this.isNavigationKey(keyEvent.key)) {
this.setOrigin('keyboard');
}
});
// Touch events
fromEvent(document, 'touchstart', { capture: true }).pipe(
takeUntil(this.destroy$)
).subscribe((event) => {
const touchEvent = event as TouchEvent;
this.lastTouchTarget = touchEvent.target;
this.setOrigin('touch');
});
// Focus events that might be programmatic
fromEvent(document, 'focus', { capture: true }).pipe(
takeUntil(this.destroy$)
).subscribe((event) => {
// If we don't have an origin, assume it's programmatic
if (this.currentFocusOrigin === null) {
this.setOrigin('program');
}
// Reset origin after a short delay
setTimeout(() => {
this.currentFocusOrigin = null;
}, 1);
});
});
}
/**
* Check if a key is a navigation key
*/
private isNavigationKey(key: string): boolean {
const navigationKeys = [
'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'Home', 'End', 'PageUp', 'PageDown', 'Enter', ' ', 'Escape'
];
return navigationKeys.includes(key);
}
}

View File

@@ -0,0 +1 @@
export * from './focus-monitor.service';

View File

@@ -0,0 +1,224 @@
import { Injectable, OnDestroy, signal } from '@angular/core';
import { Observable, fromEvent, Subject } from 'rxjs';
import { takeUntil, map, startWith, distinctUntilChanged } from 'rxjs/operators';
export interface AccessibilityPreferences {
prefersReducedMotion: boolean;
prefersHighContrast: boolean;
prefersDarkMode: boolean;
prefersReducedTransparency: boolean;
}
@Injectable({
providedIn: 'root'
})
export class HighContrastService implements OnDestroy {
private destroy$ = new Subject<void>();
// Reactive signals for preferences
readonly prefersReducedMotion = signal(false);
readonly prefersHighContrast = signal(false);
readonly prefersDarkMode = signal(false);
readonly prefersReducedTransparency = signal(false);
// Media query lists
private reducedMotionQuery?: MediaQueryList;
private highContrastQuery?: MediaQueryList;
private darkModeQuery?: MediaQueryList;
private reducedTransparencyQuery?: MediaQueryList;
constructor() {
this.setupMediaQueries();
this.updatePreferences();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Get all accessibility preferences as an observable
*/
getPreferences$(): Observable<AccessibilityPreferences> {
if (typeof window === 'undefined') {
return new Observable(subscriber => {
subscriber.next({
prefersReducedMotion: false,
prefersHighContrast: false,
prefersDarkMode: false,
prefersReducedTransparency: false
});
});
}
return fromEvent(window, 'change').pipe(
takeUntil(this.destroy$),
startWith(null),
map(() => this.getCurrentPreferences()),
distinctUntilChanged((prev, curr) =>
prev.prefersReducedMotion === curr.prefersReducedMotion &&
prev.prefersHighContrast === curr.prefersHighContrast &&
prev.prefersDarkMode === curr.prefersDarkMode &&
prev.prefersReducedTransparency === curr.prefersReducedTransparency
)
);
}
/**
* Get current accessibility preferences
*/
getCurrentPreferences(): AccessibilityPreferences {
return {
prefersReducedMotion: this.prefersReducedMotion(),
prefersHighContrast: this.prefersHighContrast(),
prefersDarkMode: this.prefersDarkMode(),
prefersReducedTransparency: this.prefersReducedTransparency()
};
}
/**
* Apply CSS classes to document body based on preferences
*/
applyPreferencesToBody(): void {
if (typeof document === 'undefined') return;
const body = document.body;
// Remove existing classes
body.classList.remove(
'prefers-reduced-motion',
'prefers-high-contrast',
'prefers-dark-mode',
'prefers-reduced-transparency'
);
// Add classes based on current preferences
if (this.prefersReducedMotion()) {
body.classList.add('prefers-reduced-motion');
}
if (this.prefersHighContrast()) {
body.classList.add('prefers-high-contrast');
}
if (this.prefersDarkMode()) {
body.classList.add('prefers-dark-mode');
}
if (this.prefersReducedTransparency()) {
body.classList.add('prefers-reduced-transparency');
}
}
/**
* Get CSS custom properties for motion duration based on reduced motion preference
*/
getMotionDuration(normalDuration: string, reducedDuration = '0.01s'): string {
return this.prefersReducedMotion() ? reducedDuration : normalDuration;
}
/**
* Get appropriate animation timing based on reduced motion preference
*/
getAnimationConfig(normal: any, reduced: any = { duration: '0.01s', easing: 'linear' }): any {
return this.prefersReducedMotion() ? reduced : normal;
}
/**
* Check if user prefers reduced motion and return appropriate CSS
*/
getReducedMotionCSS(): string {
if (this.prefersReducedMotion()) {
return `
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
`;
}
return '';
}
/**
* Setup media query listeners
*/
private setupMediaQueries(): void {
if (typeof window === 'undefined') return;
// Reduced motion
this.reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
this.reducedMotionQuery.addEventListener('change', () => {
this.updatePreferences();
this.applyPreferencesToBody();
});
// High contrast
this.highContrastQuery = window.matchMedia('(prefers-contrast: high)');
this.highContrastQuery.addEventListener('change', () => {
this.updatePreferences();
this.applyPreferencesToBody();
});
// Dark mode
this.darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.darkModeQuery.addEventListener('change', () => {
this.updatePreferences();
this.applyPreferencesToBody();
});
// Reduced transparency
this.reducedTransparencyQuery = window.matchMedia('(prefers-reduced-transparency: reduce)');
this.reducedTransparencyQuery.addEventListener('change', () => {
this.updatePreferences();
this.applyPreferencesToBody();
});
// Apply initial preferences
this.applyPreferencesToBody();
}
/**
* Update preference signals
*/
private updatePreferences(): void {
this.prefersReducedMotion.set(this.reducedMotionQuery?.matches ?? false);
this.prefersHighContrast.set(this.highContrastQuery?.matches ?? false);
this.prefersDarkMode.set(this.darkModeQuery?.matches ?? false);
this.prefersReducedTransparency.set(this.reducedTransparencyQuery?.matches ?? false);
}
/**
* Create a style element with reduced motion CSS
*/
injectReducedMotionStyles(): void {
if (typeof document === 'undefined') return;
const existingStyle = document.getElementById('a11y-reduced-motion');
if (existingStyle) return;
const style = document.createElement('style');
style.id = 'a11y-reduced-motion';
style.textContent = `
@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;
}
}
@media (prefers-contrast: high) {
* {
text-shadow: none !important;
box-shadow: none !important;
}
}
`;
document.head.appendChild(style);
}
}

View File

@@ -0,0 +1 @@
export * from './high-contrast.service';

View File

@@ -0,0 +1 @@
export * from './keyboard-manager.service';

View File

@@ -0,0 +1,251 @@
import { Injectable, NgZone, OnDestroy } from '@angular/core';
import { fromEvent, Subject } from 'rxjs';
import { takeUntil, filter } from 'rxjs/operators';
export interface KeyboardShortcut {
key: string;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
preventDefault?: boolean;
stopPropagation?: boolean;
description?: string;
handler: (event: KeyboardEvent) => void;
}
export interface KeyboardShortcutRegistration {
id: string;
shortcut: KeyboardShortcut;
element?: Element;
priority?: number;
}
@Injectable({
providedIn: 'root'
})
export class KeyboardManagerService implements OnDestroy {
private destroy$ = new Subject<void>();
private shortcuts = new Map<string, KeyboardShortcutRegistration>();
private globalShortcuts: KeyboardShortcutRegistration[] = [];
private elementShortcuts = new Map<Element, KeyboardShortcutRegistration[]>();
constructor(private ngZone: NgZone) {
this.setupGlobalListener();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.shortcuts.clear();
this.globalShortcuts.length = 0;
this.elementShortcuts.clear();
}
/**
* Register a global keyboard shortcut
*/
registerGlobalShortcut(
id: string,
shortcut: KeyboardShortcut,
priority = 0
): void {
const registration: KeyboardShortcutRegistration = {
id,
shortcut,
priority
};
this.shortcuts.set(id, registration);
this.globalShortcuts.push(registration);
// Sort by priority (higher priority first)
this.globalShortcuts.sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/**
* Register a keyboard shortcut for a specific element
*/
registerElementShortcut(
id: string,
element: Element,
shortcut: KeyboardShortcut,
priority = 0
): void {
const registration: KeyboardShortcutRegistration = {
id,
shortcut,
element,
priority
};
this.shortcuts.set(id, registration);
if (!this.elementShortcuts.has(element)) {
this.elementShortcuts.set(element, []);
this.setupElementListener(element);
}
const elementShortcuts = this.elementShortcuts.get(element)!;
elementShortcuts.push(registration);
// Sort by priority (higher priority first)
elementShortcuts.sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/**
* Unregister a keyboard shortcut
*/
unregisterShortcut(id: string): void {
const registration = this.shortcuts.get(id);
if (!registration) return;
if (registration.element) {
// Remove from element shortcuts
const elementShortcuts = this.elementShortcuts.get(registration.element);
if (elementShortcuts) {
const index = elementShortcuts.findIndex(s => s.id === id);
if (index >= 0) {
elementShortcuts.splice(index, 1);
}
// Clean up if no shortcuts left for this element
if (elementShortcuts.length === 0) {
this.elementShortcuts.delete(registration.element);
}
}
} else {
// Remove from global shortcuts
const index = this.globalShortcuts.findIndex(s => s.id === id);
if (index >= 0) {
this.globalShortcuts.splice(index, 1);
}
}
this.shortcuts.delete(id);
}
/**
* Get all registered shortcuts
*/
getShortcuts(): KeyboardShortcutRegistration[] {
return Array.from(this.shortcuts.values());
}
/**
* Get shortcuts for a specific element
*/
getElementShortcuts(element: Element): KeyboardShortcutRegistration[] {
return this.elementShortcuts.get(element) || [];
}
/**
* Get global shortcuts
*/
getGlobalShortcuts(): KeyboardShortcutRegistration[] {
return [...this.globalShortcuts];
}
/**
* Unregister all keyboard shortcuts
*/
unregisterAll(): void {
this.shortcuts.clear();
this.globalShortcuts.length = 0;
this.elementShortcuts.clear();
}
/**
* Check if a keyboard event matches a shortcut
*/
private matchesShortcut(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) {
return false;
}
return (
!!event.ctrlKey === !!shortcut.ctrlKey &&
!!event.shiftKey === !!shortcut.shiftKey &&
!!event.altKey === !!shortcut.altKey &&
!!event.metaKey === !!shortcut.metaKey
);
}
/**
* Handle keyboard event for shortcuts
*/
private handleKeyboardEvent(
event: KeyboardEvent,
shortcuts: KeyboardShortcutRegistration[]
): boolean {
for (const registration of shortcuts) {
if (this.matchesShortcut(event, registration.shortcut)) {
if (registration.shortcut.preventDefault) {
event.preventDefault();
}
if (registration.shortcut.stopPropagation) {
event.stopPropagation();
}
this.ngZone.run(() => {
registration.shortcut.handler(event);
});
return true; // Shortcut handled
}
}
return false; // No shortcut matched
}
/**
* Set up global keyboard event listener
*/
private setupGlobalListener(): void {
if (typeof document === 'undefined') return;
this.ngZone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(document, 'keydown').pipe(
takeUntil(this.destroy$),
filter(event => this.globalShortcuts.length > 0)
).subscribe(event => {
this.handleKeyboardEvent(event, this.globalShortcuts);
});
});
}
/**
* Set up keyboard event listener for a specific element
*/
private setupElementListener(element: Element): void {
this.ngZone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(element, 'keydown').pipe(
takeUntil(this.destroy$)
).subscribe(event => {
const shortcuts = this.elementShortcuts.get(element) || [];
this.handleKeyboardEvent(event, shortcuts);
});
});
}
/**
* Create a shortcut description for display purposes
*/
static getShortcutDescription(shortcut: KeyboardShortcut): string {
if (shortcut.description) {
return shortcut.description;
}
const parts: string[] = [];
if (shortcut.ctrlKey) parts.push('Ctrl');
if (shortcut.altKey) parts.push('Alt');
if (shortcut.shiftKey) parts.push('Shift');
if (shortcut.metaKey) parts.push('Meta');
parts.push(shortcut.key.toUpperCase());
return parts.join(' + ');
}
}

View File

@@ -0,0 +1 @@
export * from './live-announcer.service';

View File

@@ -0,0 +1,152 @@
import { Injectable, OnDestroy } from '@angular/core';
export type AriaLivePoliteness = 'off' | 'polite' | 'assertive';
export interface LiveAnnouncementConfig {
politeness?: AriaLivePoliteness;
duration?: number;
clearPrevious?: boolean;
}
@Injectable({
providedIn: 'root'
})
export class LiveAnnouncerService implements OnDestroy {
private liveElement?: HTMLElement;
private timeouts: Map<AriaLivePoliteness, any> = new Map();
constructor() {
this.createLiveElement();
}
ngOnDestroy(): void {
this.timeouts.forEach(timeout => clearTimeout(timeout));
this.removeLiveElement();
}
/**
* Announces a message to screen readers
*/
announce(
message: string,
politeness: AriaLivePoliteness = 'polite',
duration?: number
): void {
this.announceWithConfig(message, {
politeness,
duration,
clearPrevious: true
});
}
/**
* Announces a message with full configuration options
*/
announceWithConfig(message: string, config: LiveAnnouncementConfig = {}): void {
const {
politeness = 'polite',
duration = 4000,
clearPrevious = true
} = config;
if (!this.liveElement) {
this.createLiveElement();
}
if (!this.liveElement) return;
// Clear any existing timeout for this politeness level
if (this.timeouts.has(politeness)) {
clearTimeout(this.timeouts.get(politeness));
}
// Clear previous message if requested
if (clearPrevious) {
this.liveElement.textContent = '';
}
// Set the politeness level
this.liveElement.setAttribute('aria-live', politeness);
// Announce the message
setTimeout(() => {
if (this.liveElement) {
this.liveElement.textContent = message;
}
}, 100);
// Clear the message after the specified duration
if (duration > 0) {
const timeoutId = setTimeout(() => {
if (this.liveElement) {
this.liveElement.textContent = '';
}
this.timeouts.delete(politeness);
}, duration);
this.timeouts.set(politeness, timeoutId);
}
}
/**
* Clears all current announcements
*/
clear(): void {
if (this.liveElement) {
this.liveElement.textContent = '';
}
this.timeouts.forEach(timeout => clearTimeout(timeout));
this.timeouts.clear();
}
/**
* Announces multiple messages with delays between them
*/
announceSequence(
messages: string[],
politeness: AriaLivePoliteness = 'polite',
delayBetween = 1000
): void {
messages.forEach((message, index) => {
setTimeout(() => {
this.announce(message, politeness);
}, index * delayBetween);
});
}
/**
* Creates the live region element
*/
private createLiveElement(): void {
if (typeof document === 'undefined') return;
// Remove existing element if it exists
this.removeLiveElement();
// Create new live region
this.liveElement = document.createElement('div');
this.liveElement.setAttribute('aria-live', 'polite');
this.liveElement.setAttribute('aria-atomic', 'true');
this.liveElement.setAttribute('id', 'a11y-live-announcer');
// Make it invisible but still accessible to screen readers
this.liveElement.style.position = 'absolute';
this.liveElement.style.left = '-10000px';
this.liveElement.style.width = '1px';
this.liveElement.style.height = '1px';
this.liveElement.style.overflow = 'hidden';
document.body.appendChild(this.liveElement);
}
/**
* Removes the live region element
*/
private removeLiveElement(): void {
const existing = document.getElementById('a11y-live-announcer');
if (existing) {
existing.remove();
}
this.liveElement = undefined;
}
}

View File

@@ -0,0 +1,77 @@
// ==========================================================================
// ACCESSIBILITY TOKENS
// ==========================================================================
// Import semantic tokens for accessibility features
// ==========================================================================
// Import semantic tokens from design system
@use 'ui-design-system/src/styles/semantic/colors' as colors;
@use 'ui-design-system/src/styles/semantic/motion' as motion;
@use 'ui-design-system/src/styles/semantic/spacing' as spacing;
@use 'ui-design-system/src/styles/semantic/shadows' as shadows;
@use 'ui-design-system/src/styles/semantic/borders' as borders;
@use 'ui-design-system/src/styles/semantic/typography' as typography;
// FOCUS INDICATOR TOKENS
$a11y-focus-color: colors.$semantic-color-focus !default;
$a11y-focus-ring-color: colors.$semantic-color-focus-ring !default;
$a11y-focus-ring-width: borders.$semantic-border-focus-width !default;
$a11y-focus-shadow: shadows.$semantic-shadow-input-focus !default;
$a11y-focus-transition-duration: motion.$semantic-motion-duration-fast !default;
$a11y-focus-transition-timing: motion.$semantic-motion-easing-ease-out !default;
// SKIP LINKS TOKENS
$a11y-skip-link-bg: colors.$semantic-color-surface-primary !default;
$a11y-skip-link-color: colors.$semantic-color-text-primary !default;
$a11y-skip-link-border: colors.$semantic-color-border-focus !default;
$a11y-skip-link-padding-x: spacing.$semantic-spacing-interactive-button-padding-x !default;
$a11y-skip-link-padding-y: spacing.$semantic-spacing-interactive-button-padding-y !default;
$a11y-skip-link-border-radius: borders.$semantic-border-radius-md !default;
$a11y-skip-link-shadow: shadows.$semantic-shadow-button-focus !default;
$a11y-skip-link-z-index: 10000 !default;
// SCREEN READER ONLY TOKENS
$a11y-sr-only-position: absolute !default;
$a11y-sr-only-width: 1px !default;
$a11y-sr-only-height: 1px !default;
$a11y-sr-only-margin: -1px !default;
$a11y-sr-only-padding: 0 !default;
$a11y-sr-only-overflow: hidden !default;
$a11y-sr-only-clip: rect(0, 0, 0, 0) !default;
$a11y-sr-only-border: 0 !default;
// HIGH CONTRAST MODE TOKENS
$a11y-high-contrast-border-color: ButtonText !default;
$a11y-high-contrast-bg-color: ButtonFace !default;
$a11y-high-contrast-text-color: ButtonText !default;
$a11y-high-contrast-focus-color: Highlight !default;
// REDUCED MOTION TOKENS
$a11y-reduced-motion-duration: motion.$semantic-motion-duration-instant !default;
$a11y-reduced-motion-easing: motion.$semantic-motion-easing-linear !default;
// TOUCH TARGET TOKENS
$a11y-min-touch-target: spacing.$semantic-spacing-interactive-touch-target !default;
// LIVE REGION TOKENS
$a11y-live-region-position: absolute !default;
$a11y-live-region-left: -10000px !default;
$a11y-live-region-width: 1px !default;
$a11y-live-region-height: 1px !default;
$a11y-live-region-overflow: hidden !default;
// ERROR AND SUCCESS COLORS FOR HIGH CONTRAST
$a11y-error-color: colors.$semantic-color-error !default;
$a11y-success-color: colors.$semantic-color-success !default;
$a11y-warning-color: colors.$semantic-color-warning !default;
// ANIMATION TOKENS FOR ACCESSIBILITY
$a11y-animation-fade-duration: motion.$semantic-motion-duration-normal !default;
$a11y-animation-slide-duration: motion.$semantic-motion-duration-normal !default;
$a11y-animation-scale-duration: motion.$semantic-motion-duration-fast !default;
// FOCUS TRAP TOKENS
$a11y-focus-trap-outline-color: colors.$semantic-color-focus !default;
$a11y-focus-trap-outline-width: 2px !default;
$a11y-focus-trap-outline-style: solid !default;
$a11y-focus-trap-outline-offset: 2px !default;

View File

@@ -0,0 +1,106 @@
import { NgModule, ModuleWithProviders, inject, APP_INITIALIZER } from '@angular/core';
import { CommonModule } from '@angular/common';
// Services
import { LiveAnnouncerService } from './services/live-announcer';
import { FocusMonitorService } from './services/focus-monitor';
import { KeyboardManagerService } from './services/keyboard-manager';
import { HighContrastService } from './services/high-contrast';
import { A11yConfigService, A11yConfiguration, A11Y_CONFIG } from './services/a11y-config';
// Directives
import { FocusTrapDirective } from './directives/focus-trap';
import { ArrowNavigationDirective } from './directives/arrow-navigation';
// Components
import { SkipLinksComponent } from './components/skip-links';
import { ScreenReaderOnlyComponent } from './components/screen-reader-only';
const DIRECTIVES = [
FocusTrapDirective,
ArrowNavigationDirective
];
const COMPONENTS = [
SkipLinksComponent,
ScreenReaderOnlyComponent
];
/**
* Initialize accessibility features based on configuration
*/
export function initializeA11y(
config: A11yConfigService,
highContrast: HighContrastService
): () => void {
return () => {
// Apply body classes for accessibility preferences
if (config.isEnabled('accessibility.addBodyClasses')) {
highContrast.applyPreferencesToBody();
}
// Inject accessibility styles
if (config.isEnabled('accessibility.injectAccessibilityStyles')) {
highContrast.injectReducedMotionStyles();
}
// Log initialization
config.log('UI Accessibility module initialized', config.getConfig());
};
}
@NgModule({
declarations: [],
imports: [
CommonModule,
...DIRECTIVES,
...COMPONENTS
],
exports: [
...DIRECTIVES,
...COMPONENTS
]
})
export class UiAccessibilityModule {
static forRoot(config?: Partial<A11yConfiguration>): ModuleWithProviders<UiAccessibilityModule> {
return {
ngModule: UiAccessibilityModule,
providers: [
// Core services
LiveAnnouncerService,
FocusMonitorService,
KeyboardManagerService,
HighContrastService,
A11yConfigService,
// Configuration
{
provide: A11Y_CONFIG,
useValue: config || {}
},
// Initialize accessibility features
{
provide: APP_INITIALIZER,
useFactory: initializeA11y,
deps: [A11yConfigService, HighContrastService],
multi: true
}
]
};
}
static forChild(): ModuleWithProviders<UiAccessibilityModule> {
return {
ngModule: UiAccessibilityModule,
providers: []
};
}
constructor() {
// Module loaded
if (typeof console !== 'undefined') {
console.log('🔍 UI Accessibility module loaded');
}
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UiAccessibilityService } from './ui-accessibility.service';
describe('UiAccessibilityService', () => {
let service: UiAccessibilityService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UiAccessibilityService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,9 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class UiAccessibilityService {
constructor() { }
}

View File

@@ -0,0 +1,243 @@
// ==========================================================================
// ACCESSIBILITY UTILITIES
// ==========================================================================
// Main accessibility utility file that imports all utilities
// ==========================================================================
@use '../tokens/a11y-tokens' as tokens;
// Import all utility modules
@forward 'focus-visible';
@forward 'screen-reader';
// Skip links styles
.skip-links {
position: fixed;
top: 0;
left: 0;
z-index: tokens.$a11y-skip-link-z-index;
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: tokens.$a11y-skip-link-bg;
color: tokens.$a11y-skip-link-color;
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
text-decoration: none;
border: 1px solid tokens.$a11y-skip-link-border;
border-radius: tokens.$a11y-skip-link-border-radius;
box-shadow: tokens.$a11y-skip-link-shadow;
opacity: 0;
pointer-events: none;
transform: translateY(-10px);
transition: all tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
&:focus {
top: 6px;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
// Multiple skip links positioning
&:nth-child(2):focus {
top: 50px;
}
&:nth-child(3):focus {
top: 94px;
}
}
}
// Touch target sizing
.touch-target {
min-height: tokens.$a11y-min-touch-target;
min-width: tokens.$a11y-min-touch-target;
display: inline-flex;
align-items: center;
justify-content: center;
}
// Error announcements
.error-announcement {
@extend .sr-only;
&.error-visible {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
color: tokens.$a11y-error-color;
font-weight: 600;
margin-top: 4px;
}
}
// Loading announcements
.loading-announcement {
@extend .sr-only;
}
// Keyboard navigation indicators
.keyboard-navigation {
// Hide mouse focus indicators when using keyboard navigation
&.using-mouse {
* {
&:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
}
}
// Enhanced focus indicators when using keyboard
&.using-keyboard {
*:focus-visible {
outline: 2px solid tokens.$a11y-focus-color;
outline-offset: 2px;
}
}
}
// High contrast mode utilities
@media (prefers-contrast: high) {
.high-contrast-border {
border: 1px solid tokens.$a11y-high-contrast-border-color !important;
}
.high-contrast-bg {
background: tokens.$a11y-high-contrast-bg-color !important;
color: tokens.$a11y-high-contrast-text-color !important;
}
// Remove shadows and transparency in high contrast
* {
text-shadow: none !important;
box-shadow: none !important;
}
// Ensure sufficient contrast for interactive elements
button,
[role="button"],
a,
input,
select,
textarea {
border: 1px solid tokens.$a11y-high-contrast-border-color;
}
}
// Reduced motion utilities
@media (prefers-reduced-motion: reduce) {
.respect-reduced-motion {
* {
animation-duration: tokens.$a11y-reduced-motion-duration !important;
animation-iteration-count: 1 !important;
transition-duration: tokens.$a11y-reduced-motion-duration !important;
scroll-behavior: auto !important;
}
}
}
// ARIA live region styling
.aria-live-region {
@extend .live-region;
&.aria-live-assertive {
speak: literal;
}
&.aria-live-polite {
speak: spell-out;
}
}
// Focus management utilities
.focus-within-highlight {
&:focus-within {
outline: 1px solid tokens.$a11y-focus-color;
outline-offset: 2px;
}
}
// Accessible hide/show animations
@keyframes fadeInA11y {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutA11y {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
.fade-in-a11y {
animation: fadeInA11y tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing;
}
.fade-out-a11y {
animation: fadeOutA11y tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing;
}
// Respect reduced motion for animations
@media (prefers-reduced-motion: reduce) {
.fade-in-a11y,
.fade-out-a11y {
animation-duration: tokens.$a11y-reduced-motion-duration;
animation-timing-function: tokens.$a11y-reduced-motion-easing;
}
}
// Arrow navigation visual indicators (dev mode)
.arrow-navigation-active {
&.arrow-navigation-debug {
position: relative;
&::before {
content: '← ↑ ↓ → Arrow Navigation';
position: absolute;
top: -20px;
right: 0;
font-size: 12px;
color: tokens.$a11y-focus-color;
background: tokens.$a11y-skip-link-bg;
padding: 2px 6px;
border-radius: 3px;
z-index: 1000;
pointer-events: none;
}
}
// Highlight navigable items
[tabindex="0"] {
position: relative;
&::after {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border: 1px dashed tokens.$a11y-focus-color;
pointer-events: none;
opacity: 0.3;
}
}
}

View File

@@ -0,0 +1,145 @@
// ==========================================================================
// FOCUS VISIBLE UTILITIES
// ==========================================================================
// Focus management styles using semantic tokens
// ==========================================================================
@use '../tokens/a11y-tokens' as tokens;
// Base focus visible styles
.focus-visible,
:focus-visible {
outline: tokens.$a11y-focus-trap-outline-width tokens.$a11y-focus-trap-outline-style tokens.$a11y-focus-outline-color;
outline-offset: tokens.$a11y-focus-trap-outline-offset;
transition: outline-color tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
}
// Remove default focus outline for mouse users
:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
// Focus ring for interactive elements
.focus-ring {
position: relative;
&:focus-visible::after {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border: tokens.$a11y-focus-ring-width solid tokens.$a11y-focus-color;
border-radius: inherit;
pointer-events: none;
transition: opacity tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
opacity: 1;
}
&:not(:focus-visible)::after {
opacity: 0;
}
}
// Focus styles for buttons
.btn-focus-visible,
button:focus-visible,
[role="button"]:focus-visible {
outline: tokens.$a11y-focus-trap-outline-width solid tokens.$a11y-focus-color;
outline-offset: 2px;
box-shadow: tokens.$a11y-focus-shadow;
}
// Focus styles for form inputs
.input-focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
border-color: tokens.$a11y-focus-color;
box-shadow: tokens.$a11y-focus-shadow;
outline: none;
}
// Focus styles for links
.link-focus-visible,
a:focus-visible {
outline: tokens.$a11y-focus-trap-outline-width solid tokens.$a11y-focus-color;
outline-offset: 2px;
text-decoration: underline;
}
// High contrast mode support
@media (prefers-contrast: high) {
.focus-visible,
:focus-visible {
outline-color: tokens.$a11y-high-contrast-focus-color;
outline-width: 3px;
}
.focus-ring:focus-visible::after {
border-color: tokens.$a11y-high-contrast-focus-color;
border-width: 3px;
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
.focus-visible,
:focus-visible,
.focus-ring::after {
transition-duration: tokens.$a11y-reduced-motion-duration;
transition-timing-function: tokens.$a11y-reduced-motion-easing;
}
}
// Focus trap container styles
.focus-trap-container {
position: relative;
&.focus-trap-active {
isolation: isolate;
}
// Visual indicator for focus trap (dev mode)
&.focus-trap-debug {
&::before {
content: 'Focus Trap Active';
position: absolute;
top: -20px;
left: 0;
font-size: 12px;
color: tokens.$a11y-focus-color;
background: tokens.$a11y-skip-link-bg;
padding: 2px 6px;
border-radius: 3px;
z-index: 1000;
}
}
}
// Skip to content on focus trap
.focus-trap-skip {
position: absolute;
top: -40px;
left: 0;
background: tokens.$a11y-skip-link-bg;
color: tokens.$a11y-skip-link-color;
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
text-decoration: none;
border: 1px solid tokens.$a11y-skip-link-border;
border-radius: tokens.$a11y-skip-link-border-radius;
z-index: tokens.$a11y-skip-link-z-index;
opacity: 0;
pointer-events: none;
transform: translateY(-10px);
transition: all tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
&:focus {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
box-shadow: tokens.$a11y-skip-link-shadow;
}
}

View File

@@ -0,0 +1,167 @@
// ==========================================================================
// SCREEN READER UTILITIES
// ==========================================================================
// Screen reader specific styles using semantic tokens
// ==========================================================================
@use '../tokens/a11y-tokens' as tokens;
// Screen reader only - visually hidden but accessible
.sr-only {
position: tokens.$a11y-sr-only-position;
width: tokens.$a11y-sr-only-width;
height: tokens.$a11y-sr-only-height;
padding: tokens.$a11y-sr-only-padding;
margin: tokens.$a11y-sr-only-margin;
overflow: tokens.$a11y-sr-only-overflow;
clip: tokens.$a11y-sr-only-clip;
white-space: nowrap;
border: tokens.$a11y-sr-only-border;
}
// Screen reader only that becomes visible on focus
.sr-only-focusable {
@extend .sr-only;
&:focus,
&:active {
position: static;
width: auto;
height: auto;
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
background: tokens.$a11y-skip-link-bg;
color: tokens.$a11y-skip-link-color;
border: 1px solid tokens.$a11y-skip-link-border;
border-radius: tokens.$a11y-skip-link-border-radius;
box-shadow: tokens.$a11y-skip-link-shadow;
z-index: tokens.$a11y-skip-link-z-index;
}
}
// Live region for screen reader announcements
.live-region {
position: tokens.$a11y-live-region-position;
left: tokens.$a11y-live-region-left;
width: tokens.$a11y-live-region-width;
height: tokens.$a11y-live-region-height;
overflow: tokens.$a11y-live-region-overflow;
}
// Status message styling
.status-message {
@extend .sr-only;
&.status-visible {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
margin: 4px 0;
background: tokens.$a11y-skip-link-bg;
border-left: 4px solid tokens.$a11y-focus-color;
border-radius: 0 tokens.$a11y-skip-link-border-radius tokens.$a11y-skip-link-border-radius 0;
}
&.status-error {
border-left-color: tokens.$a11y-error-color;
}
&.status-success {
border-left-color: tokens.$a11y-success-color;
}
&.status-warning {
border-left-color: tokens.$a11y-warning-color;
}
}
// Screen reader instructions
.sr-instructions {
@extend .sr-only;
// Common instruction patterns
&.sr-required::after {
content: ' (required)';
}
&.sr-optional::after {
content: ' (optional)';
}
&.sr-expanded::after {
content: ' (expanded)';
}
&.sr-collapsed::after {
content: ' (collapsed)';
}
&.sr-selected::after {
content: ' (selected)';
}
&.sr-current::after {
content: ' (current)';
}
}
// Accessible hide/show with transitions
.a11y-hide {
opacity: 0;
pointer-events: none;
transform: scale(0.95);
transition: opacity tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing,
transform tokens.$a11y-animation-scale-duration tokens.$a11y-focus-transition-timing;
// Still accessible to screen readers when hidden
&:not(.a11y-hide-completely) {
position: absolute;
left: -9999px;
}
// Completely hidden including from screen readers
&.a11y-hide-completely {
display: none;
}
}
.a11y-show {
opacity: 1;
pointer-events: auto;
transform: scale(1);
transition: opacity tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing,
transform tokens.$a11y-animation-scale-duration tokens.$a11y-focus-transition-timing;
}
// High contrast mode adjustments
@media (prefers-contrast: high) {
.sr-only-focusable:focus,
.sr-only-focusable:active {
background: tokens.$a11y-high-contrast-bg-color;
color: tokens.$a11y-high-contrast-text-color;
border-color: tokens.$a11y-high-contrast-border-color;
}
.status-message.status-visible {
background: tokens.$a11y-high-contrast-bg-color;
color: tokens.$a11y-high-contrast-text-color;
border-color: tokens.$a11y-high-contrast-border-color;
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
.a11y-hide,
.a11y-show,
.sr-only-focusable {
transition-duration: tokens.$a11y-reduced-motion-duration;
transition-timing-function: tokens.$a11y-reduced-motion-easing;
}
}

View File

@@ -0,0 +1,21 @@
/*
* Public API Surface of ui-accessibility
*/
// Main module
export * from './lib/ui-accessibility.module';
// Services
export * from './lib/services/live-announcer';
export * from './lib/services/focus-monitor';
export * from './lib/services/keyboard-manager';
export * from './lib/services/high-contrast';
export * from './lib/services/a11y-config';
// Directives
export * from './lib/directives/focus-trap';
export * from './lib/directives/arrow-navigation';
// Components
export * from './lib/components/skip-links';
export * from './lib/components/screen-reader-only';

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}