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:
364
projects/ui-accessibility/README.md
Normal file
364
projects/ui-accessibility/README.md
Normal 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.
|
||||
7
projects/ui-accessibility/ng-package.json
Normal file
7
projects/ui-accessibility/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/ui-accessibility",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
15
projects/ui-accessibility/package.json
Normal file
15
projects/ui-accessibility/package.json
Normal 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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './screen-reader-only.component';
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './skip-links.component';
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './arrow-navigation.directive';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './focus-trap.directive';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './a11y-config.service';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './focus-monitor.service';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './high-contrast.service';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './keyboard-manager.service';
|
||||
@@ -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(' + ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './live-announcer.service';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
77
projects/ui-accessibility/src/lib/tokens/a11y-tokens.scss
Normal file
77
projects/ui-accessibility/src/lib/tokens/a11y-tokens.scss
Normal 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;
|
||||
106
projects/ui-accessibility/src/lib/ui-accessibility.module.ts
Normal file
106
projects/ui-accessibility/src/lib/ui-accessibility.module.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UiAccessibilityService {
|
||||
|
||||
constructor() { }
|
||||
}
|
||||
243
projects/ui-accessibility/src/lib/utilities/a11y-utilities.scss
Normal file
243
projects/ui-accessibility/src/lib/utilities/a11y-utilities.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
145
projects/ui-accessibility/src/lib/utilities/focus-visible.scss
Normal file
145
projects/ui-accessibility/src/lib/utilities/focus-visible.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
167
projects/ui-accessibility/src/lib/utilities/screen-reader.scss
Normal file
167
projects/ui-accessibility/src/lib/utilities/screen-reader.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
21
projects/ui-accessibility/src/public-api.ts
Normal file
21
projects/ui-accessibility/src/public-api.ts
Normal 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';
|
||||
15
projects/ui-accessibility/tsconfig.lib.json
Normal file
15
projects/ui-accessibility/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
11
projects/ui-accessibility/tsconfig.lib.prod.json
Normal file
11
projects/ui-accessibility/tsconfig.lib.prod.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
projects/ui-accessibility/tsconfig.spec.json
Normal file
15
projects/ui-accessibility/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user