Add comprehensive library expansion with new components and demos

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

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

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

942
UI_LANDING_PAGES_PLAN.md Normal file
View File

@@ -0,0 +1,942 @@
# UI Landing Pages Library Implementation Plan
## Executive Summary
This document outlines the implementation strategy for creating a comprehensive `ui-landing-pages` library within the SSuite Angular workspace. The library will provide production-ready components specifically designed for building modern websites and landing pages, integrating seamlessly with existing libraries: `ui-design-system`, `ui-essentials`, `ui-animations`, and `shared-utils`.
## Architecture Overview
### Library Structure
```
projects/ui-landing-pages/
├── src/
│ ├── lib/
│ │ ├── components/
│ │ │ ├── heroes/ # Hero section components
│ │ │ ├── features/ # Feature showcase components
│ │ │ ├── social-proof/ # Testimonials, logos, stats
│ │ │ ├── conversion/ # CTAs, pricing, forms
│ │ │ ├── navigation/ # Headers, menus, footers
│ │ │ ├── content/ # FAQ, team, timeline
│ │ │ └── templates/ # Complete page templates
│ │ ├── services/ # Landing page utilities
│ │ ├── interfaces/ # TypeScript interfaces
│ │ └── styles/ # Component-specific styles
│ ├── public-api.ts
│ └── test.ts
├── ng-package.json
├── package.json
├── tsconfig.lib.json
└── README.md
```
### Design System Integration
#### Typography (Google Fonts)
- **Primary Font**: Inter (headings, UI elements)
- **Secondary Font**: Source Sans Pro (body text)
- **Accent Font**: Roboto Mono (code, technical content)
#### Design Token Usage
```scss
// Import design system tokens
@use 'ui-design-system/src/styles/tokens' as tokens;
.hero-section {
padding: tokens.$spacing-section-large tokens.$spacing-container;
background: tokens.$color-surface-primary;
.hero-title {
font-family: tokens.$font-family-heading;
font-size: tokens.$font-size-display-large;
color: tokens.$color-text-primary;
margin-bottom: tokens.$spacing-content-medium;
}
}
```
#### Spacing Hierarchy
- **Section Padding**: `$spacing-section-large` (96px), `$spacing-section-medium` (64px)
- **Content Spacing**: `$spacing-content-large` (48px), `$spacing-content-medium` (32px)
- **Element Spacing**: `$spacing-element-large` (24px), `$spacing-element-medium` (16px)
## Implementation Phases
---
## Phase 1: Foundation & Hero Components
### Objective
Establish library foundation and implement essential hero section components.
### Timeline: 2-3 weeks
### Components to Implement
#### 1.1 HeroSection Component
```typescript
interface HeroSectionConfig {
title: string;
subtitle?: string;
ctaPrimary?: CTAButton;
ctaSecondary?: CTAButton;
backgroundType?: 'solid' | 'gradient' | 'image' | 'video';
alignment?: 'left' | 'center' | 'right';
}
```
**Features:**
- Responsive typography scaling
- Multiple background options
- Flexible CTA button layout
- Integration with ui-animations for entrance effects
#### 1.2 HeroWithImage Component
```typescript
interface HeroWithImageConfig extends HeroSectionConfig {
image: {
src: string;
alt: string;
position: 'left' | 'right' | 'background';
};
layout: 'split' | 'overlay' | 'background';
}
```
**Features:**
- 50/50 split layouts
- Image overlay with opacity controls
- Lazy loading integration
- Responsive image handling
#### 1.3 HeroSplitScreen Component
```typescript
interface HeroSplitScreenConfig {
leftContent: HeroContent;
rightContent: HeroContent | MediaContent;
verticalAlignment?: 'top' | 'center' | 'bottom';
reverseOnMobile?: boolean;
}
```
**Features:**
- Equal split responsive layout
- Content/media combinations
- Mobile stacking options
- Vertical alignment controls
### Integration Requirements
#### UI Essentials Integration
```typescript
import { ButtonComponent } from 'ui-essentials';
import { ContainerComponent } from 'ui-essentials';
import { FlexComponent } from 'ui-essentials';
// Use existing button component for CTAs
@Component({
template: `
<ui-container [maxWidth]="'xl'" [padding]="'large'">
<ui-flex [direction]="'column'" [gap]="'large'">
<!-- Hero content -->
<ui-button
[variant]="ctaPrimary.variant"
[size]="'large'"
(click)="ctaPrimary.action()">
{{ ctaPrimary.text }}
</ui-button>
</ui-flex>
</ui-container>
`
})
```
#### Design System Integration
```scss
@use 'ui-design-system/src/styles/tokens' as tokens;
@use 'ui-design-system/src/styles/mixins' as mixins;
.hero-section {
@include mixins.section-padding();
background: linear-gradient(
135deg,
tokens.$color-primary-500,
tokens.$color-primary-700
);
&__title {
@include mixins.typography-display-large();
color: tokens.$color-text-on-primary;
margin-bottom: tokens.$spacing-content-medium;
}
&__subtitle {
@include mixins.typography-body-large();
color: tokens.$color-text-on-primary-muted;
}
}
```
### Demo Implementation
- Create `hero-sections-demo` component
- Showcase all hero variants
- Include interactive configuration panel
- Demonstrate responsive behavior
### Deliverables
1. Library project structure
2. Three hero components with full functionality
3. Comprehensive demo page
4. Unit tests for all components
5. Documentation with usage examples
---
## Phase 2: Feature Sections & Social Proof
### Objective
Build components for showcasing product features and establishing credibility.
### Timeline: 3-4 weeks
### Components to Implement
#### 2.1 FeatureGrid Component
```typescript
interface FeatureGridConfig {
features: FeatureItem[];
columns: 2 | 3 | 4;
iconStyle: 'outline' | 'filled' | 'duotone';
layout: 'card' | 'minimal' | 'centered';
}
interface FeatureItem {
icon: string; // FontAwesome icon name
title: string;
description: string;
link?: { text: string; url: string; };
}
```
**Integration:**
- Use `ui-essentials` Card component as base
- Apply `ui-design-system` grid system
- Integrate FontAwesome icons
- Add hover animations from `ui-animations`
#### 2.2 FeatureShowcase Component
```typescript
interface FeatureShowcaseConfig {
features: FeatureShowcaseItem[];
alternateLayout: boolean;
imageAspectRatio: '16:9' | '4:3' | '1:1';
}
interface FeatureShowcaseItem {
title: string;
description: string;
image: string;
benefits?: string[];
cta?: CTAButton;
}
```
**Features:**
- Alternating left/right layouts
- Image lazy loading
- Smooth scroll animations
- Responsive stacking
#### 2.3 TestimonialCarousel Component
```typescript
interface TestimonialCarouselConfig {
testimonials: Testimonial[];
autoplay: boolean;
showAvatars: boolean;
variant: 'card' | 'quote' | 'minimal';
}
interface Testimonial {
quote: string;
author: string;
title?: string;
company?: string;
avatar?: string;
rating?: number;
}
```
**Integration:**
- Extend existing carousel functionality
- Use `ui-essentials` Avatar component
- Apply smooth transitions from `ui-animations`
- Implement touch/swipe gestures
#### 2.4 LogoCloud Component
```typescript
interface LogoCloudConfig {
logos: CompanyLogo[];
layout: 'grid' | 'marquee' | 'carousel';
grayscale: boolean;
hoverEffects: boolean;
}
interface CompanyLogo {
name: string;
logo: string;
url?: string;
width?: number;
}
```
#### 2.5 StatisticsDisplay Component
```typescript
interface StatisticsDisplayConfig {
stats: StatisticItem[];
layout: 'horizontal' | 'grid' | 'centered';
animateNumbers: boolean;
backgroundVariant: 'transparent' | 'subtle' | 'accent';
}
interface StatisticItem {
value: string | number;
label: string;
suffix?: string;
prefix?: string;
description?: string;
}
```
### Styling Architecture
```scss
// Feature components base styles
.feature-grid {
@include mixins.responsive-grid(
$mobile: 1,
$tablet: 2,
$desktop: var(--columns, 3)
);
gap: tokens.$spacing-content-large;
&__item {
@include mixins.card-base();
padding: tokens.$spacing-content-medium;
border-radius: tokens.$border-radius-large;
&:hover {
@include mixins.elevation-hover();
transform: translateY(-2px);
transition: tokens.$transition-medium;
}
}
&__icon {
width: 48px;
height: 48px;
color: tokens.$color-primary-500;
margin-bottom: tokens.$spacing-element-medium;
}
&__title {
@include mixins.typography-heading-small();
margin-bottom: tokens.$spacing-element-small;
}
&__description {
@include mixins.typography-body-medium();
color: tokens.$color-text-secondary;
}
}
```
### Demo Implementation
- `feature-sections-demo` with grid and showcase variants
- `social-proof-demo` with testimonials and logos
- Interactive configuration panels
- Performance optimization examples
---
## Phase 3: Conversion & Call-to-Action
### Objective
Implement high-converting components focused on user actions and conversions.
### Timeline: 3-4 weeks
### Components to Implement
#### 3.1 CTASection Component
```typescript
interface CTASectionConfig {
title: string;
description?: string;
ctaPrimary: CTAButton;
ctaSecondary?: CTAButton;
backgroundType: 'gradient' | 'pattern' | 'image' | 'solid';
urgency?: UrgencyConfig;
}
interface UrgencyConfig {
type: 'countdown' | 'limited-offer' | 'social-proof';
text: string;
endDate?: Date;
remaining?: number;
}
```
**Features:**
- Multiple background variants
- Urgency indicators
- A/B testing data attributes
- Conversion tracking hooks
#### 3.2 PricingTable Component
```typescript
interface PricingTableConfig {
plans: PricingPlan[];
billingToggle: BillingToggle;
featuresComparison: boolean;
highlightedPlan?: string;
}
interface PricingPlan {
id: string;
name: string;
price: PriceStructure;
features: PricingFeature[];
cta: CTAButton;
badge?: string;
popular?: boolean;
}
interface PriceStructure {
monthly: number;
yearly: number;
currency: string;
suffix?: string; // per user, per month, etc.
}
```
**Integration:**
- Use `ui-essentials` Table component for feature comparison
- Implement smooth toggle animations
- Apply pricing card hover effects
- Include form validation for CTAs
#### 3.3 NewsletterSignup Component
```typescript
interface NewsletterSignupConfig {
title: string;
description?: string;
placeholder: string;
ctaText: string;
privacyText?: string;
successMessage: string;
variant: 'inline' | 'modal' | 'sidebar' | 'footer';
}
```
**Features:**
- Email validation
- GDPR compliance options
- Success/error states
- Integration with email services
#### 3.4 ContactForm Component
```typescript
interface ContactFormConfig {
fields: FormField[];
submitText: string;
successMessage: string;
layout: 'single-column' | 'two-column' | 'inline';
validation: ValidationRules;
}
interface FormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'checkbox';
name: string;
label: string;
placeholder?: string;
required: boolean;
options?: SelectOption[];
}
```
**Integration:**
- Extend `ui-essentials` form components
- Apply `shared-utils` validation
- Include accessibility features
- Support for custom field types
### Advanced Styling
```scss
// CTA Section with gradient backgrounds
.cta-section {
@include mixins.section-padding();
position: relative;
overflow: hidden;
&--gradient {
background: linear-gradient(
135deg,
tokens.$color-primary-500 0%,
tokens.$color-primary-700 50%,
tokens.$color-accent-500 100%
);
}
&--pattern {
background-color: tokens.$color-surface-secondary;
background-image: url('data:image/svg+xml,...'); // Pattern SVG
background-size: 60px 60px;
}
&__content {
position: relative;
z-index: 2;
max-width: 800px;
margin: 0 auto;
text-align: center;
}
&__title {
@include mixins.typography-display-medium();
color: tokens.$color-text-on-primary;
margin-bottom: tokens.$spacing-content-medium;
}
}
// Pricing table responsive design
.pricing-table {
@include mixins.responsive-grid(
$mobile: 1,
$tablet: 2,
$desktop: 3
);
gap: tokens.$spacing-content-medium;
&__plan {
@include mixins.card-elevated();
position: relative;
padding: tokens.$spacing-content-large;
border-radius: tokens.$border-radius-large;
&--popular {
border: 2px solid tokens.$color-primary-500;
transform: scale(1.05);
&::before {
content: 'Most Popular';
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: tokens.$color-primary-500;
color: tokens.$color-text-on-primary;
padding: tokens.$spacing-element-small tokens.$spacing-element-medium;
border-radius: tokens.$border-radius-medium;
font-size: tokens.$font-size-small;
font-weight: tokens.$font-weight-semibold;
}
}
}
}
```
---
## Phase 4: Navigation & Footer
### Objective
Create comprehensive navigation and footer systems for complete website layouts.
### Timeline: 2-3 weeks
### Components to Implement
#### 4.1 LandingHeader Component
```typescript
interface LandingHeaderConfig {
logo: LogoConfig;
navigation: NavigationItem[];
cta?: CTAButton;
variant: 'transparent' | 'solid' | 'blur';
sticky: boolean;
mobileBreakpoint: number;
}
interface LogoConfig {
src?: string;
text?: string;
href: string;
width?: number;
height?: number;
}
interface NavigationItem {
label: string;
href?: string;
children?: NavigationItem[];
megaMenu?: MegaMenuConfig;
}
```
**Features:**
- Smooth scroll spy
- Mobile hamburger menu
- Transparent/solid transitions
- Mega menu support
#### 4.2 MegaMenu Component
```typescript
interface MegaMenuConfig {
columns: MegaMenuColumn[];
width: 'container' | 'full' | 'auto';
showOnHover: boolean;
}
interface MegaMenuColumn {
title?: string;
items: NavigationItem[];
featured?: FeaturedContent;
}
```
#### 4.3 FooterSection Component
```typescript
interface FooterSectionConfig {
columns: FooterColumn[];
newsletter?: NewsletterSignupConfig;
socialLinks?: SocialLink[];
copyright: string;
logo?: LogoConfig;
variant: 'simple' | 'comprehensive' | 'minimal';
}
interface FooterColumn {
title: string;
links: FooterLink[];
}
interface FooterLink {
text: string;
href: string;
external?: boolean;
}
```
#### 4.4 StickyNavbar Component
```typescript
interface StickyNavbarConfig extends LandingHeaderConfig {
hideOnScroll: boolean;
showAfter: number; // pixels
shrinkOnScroll: boolean;
}
```
### Navigation Styling
```scss
.landing-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
transition: tokens.$transition-medium;
&--transparent {
background: transparent;
backdrop-filter: blur(10px);
}
&--solid {
background: tokens.$color-surface-primary;
box-shadow: tokens.$shadow-small;
}
&__container {
@include mixins.container-max-width();
display: flex;
align-items: center;
justify-content: space-between;
padding: tokens.$spacing-element-medium tokens.$spacing-container;
}
&__nav {
display: flex;
align-items: center;
gap: tokens.$spacing-element-large;
@media (max-width: tokens.$breakpoint-tablet) {
display: none;
}
}
&__nav-item {
@include mixins.typography-body-medium();
color: tokens.$color-text-primary;
text-decoration: none;
position: relative;
padding: tokens.$spacing-element-small 0;
&:hover {
color: tokens.$color-primary-500;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: tokens.$color-primary-500;
transition: width tokens.$transition-fast;
}
&:hover::after {
width: 100%;
}
}
}
```
---
## Phase 5: Content Sections & Templates
### Objective
Complete the library with supplementary content components and ready-to-use page templates.
### Timeline: 4-5 weeks
### Components to Implement
#### 5.1 Content Components
##### FAQSection Component
```typescript
interface FAQSectionConfig {
title: string;
description?: string;
faqs: FAQItem[];
searchable: boolean;
categories?: string[];
}
interface FAQItem {
id: string;
question: string;
answer: string;
category?: string;
}
```
##### TeamGrid Component
```typescript
interface TeamGridConfig {
title: string;
members: TeamMember[];
columns: 2 | 3 | 4;
showSocial: boolean;
}
interface TeamMember {
name: string;
role: string;
bio?: string;
avatar: string;
social?: SocialLinks;
}
```
##### TimelineSection Component
```typescript
interface TimelineSectionConfig {
title: string;
events: TimelineEvent[];
orientation: 'vertical' | 'horizontal';
variant: 'minimal' | 'detailed' | 'milestone';
}
interface TimelineEvent {
date: string;
title: string;
description: string;
image?: string;
milestone?: boolean;
}
```
#### 5.2 Page Templates
##### SaaSLandingPage Template
```typescript
interface SaaSLandingPageConfig {
hero: HeroSectionConfig;
features: FeatureGridConfig;
socialProof: TestimonialCarouselConfig;
pricing: PricingTableConfig;
cta: CTASectionConfig;
footer: FooterSectionConfig;
}
```
**Template Structure:**
1. Header with transparent navigation
2. Hero section with product demo
3. Feature showcase (3-column grid)
4. Social proof (testimonials + logo cloud)
5. Pricing section
6. FAQ section
7. Final CTA section
8. Comprehensive footer
##### ProductLandingPage Template
```typescript
interface ProductLandingPageConfig {
hero: HeroWithImageConfig;
features: FeatureShowcaseConfig;
gallery: ProductGallery;
reviews: ReviewSection;
purchase: PurchaseSection;
}
```
##### AgencyLandingPage Template
```typescript
interface AgencyLandingPageConfig {
hero: HeroSectionConfig;
services: FeatureGridConfig;
portfolio: PortfolioSection;
team: TeamGridConfig;
testimonials: TestimonialCarouselConfig;
contact: ContactFormConfig;
}
```
### Template Implementation
```typescript
@Component({
selector: 'ui-saas-landing-page',
template: `
<ui-landing-header [config]="config.header"></ui-landing-header>
<main>
<ui-hero-section [config]="config.hero"></ui-hero-section>
<ui-page-section background="subtle">
<ui-feature-grid [config]="config.features"></ui-feature-grid>
</ui-page-section>
<ui-page-section>
<ui-testimonial-carousel [config]="config.testimonials"></ui-testimonial-carousel>
</ui-page-section>
<ui-page-section background="accent">
<ui-pricing-table [config]="config.pricing"></ui-pricing-table>
</ui-page-section>
<ui-page-section>
<ui-faq-section [config]="config.faq"></ui-faq-section>
</ui-page-section>
<ui-cta-section [config]="config.cta"></ui-cta-section>
</main>
<ui-footer-section [config]="config.footer"></ui-footer-section>
`,
styleUrls: ['./saas-landing-page.component.scss']
})
export class SaaSLandingPageComponent {
@Input() config!: SaaSLandingPageConfig;
}
```
## Technical Specifications
### Performance Requirements
- Lazy loading for all images
- Code splitting by component category
- Tree-shakeable exports
- Optimal bundle size (<50kb per component category)
- Lighthouse score >90 for demo pages
### Accessibility Standards
- WCAG 2.1 AA compliance
- Proper ARIA labels and roles
- Keyboard navigation support
- Screen reader optimization
- Color contrast ratios >4.5:1
### Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile browsers (iOS Safari, Chrome Mobile)
### Testing Strategy
- Unit tests: >90% coverage
- Component integration tests
- Visual regression tests
- Performance benchmarks
- Accessibility automated testing
### Documentation Requirements
- Storybook integration
- API documentation with examples
- Usage guidelines
- Design system integration guide
- Migration guides
## Integration Checklist
### Design System Integration
- [ ] Import design tokens correctly
- [ ] Use consistent spacing hierarchy
- [ ] Apply typography scale
- [ ] Implement color system
- [ ] Follow component naming conventions
### UI Essentials Integration
- [ ] Extend existing components where applicable
- [ ] Reuse layout components (Container, Flex, Grid)
- [ ] Apply consistent button styles
- [ ] Use existing form components
- [ ] Maintain consistent component API patterns
### Animation Integration
- [ ] Apply entrance animations
- [ ] Add hover/interaction effects
- [ ] Implement scroll-triggered animations
- [ ] Use consistent easing curves
- [ ] Optimize for performance
### Utilities Integration
- [ ] Use shared validation logic
- [ ] Apply common helper functions
- [ ] Implement consistent error handling
- [ ] Use shared interfaces where applicable
## Success Metrics
### Development Metrics
- All components implement consistent APIs
- 100% TypeScript coverage
- Zero accessibility violations
- Performance budgets met
- Documentation completeness
### Usage Metrics
- Component adoption rate
- Bundle size impact
- Build time impact
- Developer satisfaction scores
- Community contributions
## Conclusion
This implementation plan provides a structured approach to building a comprehensive ui-landing-pages library that integrates seamlessly with the existing SSuite ecosystem. By following this phased approach, the team can deliver value incrementally while maintaining high quality standards and consistency across all components.
The resulting library will enable rapid development of professional landing pages and marketing websites while ensuring accessibility, performance, and maintainability standards are met.

View File

@@ -196,6 +196,237 @@
}
}
}
},
"ui-animations": {
"projectType": "library",
"root": "projects/ui-animations",
"sourceRoot": "projects/ui-animations/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/ui-animations/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/ui-animations/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/ui-animations/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/ui-animations/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
},
"ui-accessibility": {
"projectType": "library",
"root": "projects/ui-accessibility",
"sourceRoot": "projects/ui-accessibility/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/ui-accessibility/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/ui-accessibility/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/ui-accessibility/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/ui-accessibility/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
},
"ui-data-utils": {
"projectType": "library",
"root": "projects/ui-data-utils",
"sourceRoot": "projects/ui-data-utils/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/ui-data-utils/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/ui-data-utils/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/ui-data-utils/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/ui-data-utils/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
},
"hcl-studio": {
"projectType": "library",
"root": "projects/hcl-studio",
"sourceRoot": "projects/hcl-studio/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/hcl-studio/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/hcl-studio/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/hcl-studio/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/hcl-studio/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
},
"ui-font-manager": {
"projectType": "library",
"root": "projects/ui-font-manager",
"sourceRoot": "projects/ui-font-manager/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/ui-font-manager/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/ui-font-manager/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/ui-font-manager/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/ui-font-manager/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
},
"ui-code-display": {
"projectType": "library",
"root": "projects/ui-code-display",
"sourceRoot": "projects/ui-code-display/src",
"prefix": "ui",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/ui-code-display/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/ui-code-display/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/ui-code-display/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/ui-code-display/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
},
"ui-backgrounds": {
"projectType": "library",
"root": "projects/ui-backgrounds",
"sourceRoot": "projects/ui-backgrounds/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/ui-backgrounds/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/ui-backgrounds/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/ui-backgrounds/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/ui-backgrounds/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
}
}
}

16
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@fortawesome/free-brands-svg-icons": "^7.0.0",
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"prismjs": "^1.30.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -29,6 +30,7 @@
"@angular/cli": "^19.2.15",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"@types/prismjs": "^1.26.5",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
@@ -5252,6 +5254,12 @@
"@types/node": "*"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"dev": true
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -11266,6 +11274,14 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"engines": {
"node": ">=6"
}
},
"node_modules/proc-log": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",

View File

@@ -22,6 +22,7 @@
"@fortawesome/free-brands-svg-icons": "^7.0.0",
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"prismjs": "^1.30.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -31,6 +32,7 @@
"@angular/cli": "^19.2.15",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"@types/prismjs": "^1.26.5",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",

View File

@@ -1,8 +1,48 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';
import { UiAccessibilityModule } from 'ui-accessibility';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
importProvidersFrom(
UiAccessibilityModule.forRoot({
announcer: {
defaultPoliteness: 'polite',
defaultDuration: 4000,
enabled: true
},
focusManagement: {
trapFocus: true,
restoreFocus: true,
focusVisibleEnabled: true
},
keyboard: {
enableShortcuts: true,
enableArrowNavigation: true
},
skipLinks: {
enabled: true,
position: 'top',
links: [
{ href: '#main', text: 'Skip to main content' },
{ href: '#nav', text: 'Skip to navigation' }
]
},
accessibility: {
respectReducedMotion: true,
respectHighContrast: true,
injectAccessibilityStyles: true,
addBodyClasses: true
},
development: {
warnings: true,
logging: true
}
})
)
]
};

View File

@@ -0,0 +1,274 @@
<div class="accessibility-demo">
<!-- Skip Links Demo -->
<ui-skip-links></ui-skip-links>
<header class="demo-header">
<h1 id="main">UI Accessibility Library Demo</h1>
<p>Interactive examples of accessibility features with WCAG 2.1 Level AA compliance.</p>
<ui-screen-reader-only>
This page demonstrates various accessibility features. Use keyboard shortcuts: Ctrl+Shift+A to make announcements, Ctrl+M to toggle modal.
</ui-screen-reader-only>
</header>
<!-- Current Preferences Display -->
<section class="demo-section" aria-labelledby="preferences-heading">
<h2 id="preferences-heading">Accessibility Preferences</h2>
<div class="preferences-display">
<p><strong>High Contrast:</strong> {{ currentPreferences.prefersHighContrast ? 'Enabled' : 'Disabled' }}</p>
<p><strong>Reduced Motion:</strong> {{ currentPreferences.prefersReducedMotion ? 'Enabled' : 'Disabled' }}</p>
<p><strong>Color Scheme:</strong> {{ currentPreferences.colorScheme || 'Not detected' }}</p>
<ui-button variant="tonal" size="small" (click)="refreshPreferences()">
Refresh Preferences
</ui-button>
</div>
</section>
<!-- Live Announcements Demo -->
<section class="demo-section" aria-labelledby="announcements-heading">
<h2 id="announcements-heading">Live Announcements</h2>
<div class="demo-controls">
<div class="input-group">
<label for="announcement-text">Announcement Text:</label>
<input
id="announcement-text"
type="text"
[(ngModel)]="announcementText"
class="demo-input"
>
</div>
<div class="input-group">
<label for="politeness-level">Politeness Level:</label>
<select id="politeness-level" [(ngModel)]="selectedPoliteness" class="demo-select">
<option value="polite">Polite</option>
<option value="assertive">Assertive</option>
</select>
</div>
<div class="button-group">
<ui-button (click)="makeAnnouncement()">Make Announcement</ui-button>
<ui-button variant="tonal" (click)="makeSequenceAnnouncement()">Sequence Announcement</ui-button>
</div>
<div class="button-group">
<ui-button variant="filled" (click)="announceSuccess()">Success Message</ui-button>
<ui-button variant="outlined" (click)="announceError()">Error Message</ui-button>
<ui-button variant="tonal" (click)="announceLoading()">Loading Message</ui-button>
<ui-button variant="outlined" (click)="simulateFormValidation()">Simulate Validation</ui-button>
</div>
</div>
</section>
<!-- Focus Management Demo -->
<section class="demo-section" aria-labelledby="focus-heading">
<h2 id="focus-heading">Focus Management</h2>
<div class="focus-demo">
<div class="input-group">
<label for="monitored-input">Monitored Input (Focus Origin: {{ focusOrigin }}):</label>
<input
#monitoredInput
id="monitored-input"
type="text"
placeholder="Click, tab, or programmatically focus this input"
class="demo-input"
>
</div>
<div class="button-group">
<ui-button (click)="focusViaKeyboard()">Focus via Keyboard</ui-button>
<ui-button (click)="focusViaMouse()">Focus via Mouse</ui-button>
<ui-button (click)="focusViaProgram()">Focus via Program</ui-button>
</div>
</div>
</section>
<!-- Focus Trap Demo -->
<section class="demo-section" aria-labelledby="trap-heading">
<h2 id="trap-heading">Focus Trap</h2>
<div class="trap-controls">
<ui-button (click)="toggleFocusTrap()">
{{ trapEnabled ? 'Disable' : 'Enable' }} Focus Trap
</ui-button>
</div>
<div
#trapContainer
class="focus-trap-container"
[class.trap-enabled]="trapEnabled"
[uiFocusTrap]="trapEnabled"
[autoFocus]="true"
[restoreFocus]="true"
>
<h3>Focus Trap Container {{ trapEnabled ? '(Active)' : '(Inactive)' }}</h3>
<p>When enabled, Tab navigation will be trapped within this container.</p>
<input type="text" placeholder="First input" class="demo-input">
<input type="text" placeholder="Second input" class="demo-input">
<ui-button>Trapped Button 1</ui-button>
<ui-button variant="tonal">Trapped Button 2</ui-button>
<select class="demo-select">
<option>Option 1</option>
<option>Option 2</option>
</select>
<textarea placeholder="Textarea" class="demo-textarea"></textarea>
</div>
</section>
<!-- Arrow Navigation Demo -->
<section class="demo-section" aria-labelledby="navigation-heading">
<h2 id="navigation-heading">Arrow Navigation</h2>
<div class="navigation-demo">
<h3>Vertical Navigation (Use ↑↓ arrows):</h3>
<ul
class="navigation-list vertical"
uiArrowNavigation="vertical"
[wrap]="true"
(navigationChange)="onNavigationChange($event)"
>
@for (item of navigationItems; track item.id) {
<li
[tabindex]="$first ? '0' : '-1'"
[class.disabled]="item.disabled"
[attr.aria-disabled]="item.disabled"
>
{{ item.label }}
</li>
}
</ul>
<h3>Grid Navigation (Use ↑↓←→ arrows, 3 columns):</h3>
<div
class="navigation-grid"
uiArrowNavigation="grid"
[gridColumns]="3"
[wrap]="true"
(navigationChange)="onGridNavigationChange($event)"
>
@for (item of gridItems; track item.id) {
<button
class="grid-item"
[tabindex]="$first ? '0' : '-1'"
[disabled]="item.disabled"
[attr.aria-disabled]="item.disabled"
>
{{ item.label }}
</button>
}
</div>
</div>
</section>
<!-- Modal Demo -->
<section class="demo-section" aria-labelledby="modal-heading">
<h2 id="modal-heading">Modal with Focus Trap</h2>
<ui-button (click)="toggleModal()">Open Modal</ui-button>
@if (showModal) {
<div class="modal-backdrop" (click)="toggleModal()">
<div
class="modal-content"
[uiFocusTrap]="true"
[autoFocus]="true"
[restoreFocus]="true"
role="dialog"
aria-labelledby="modal-title"
aria-modal="true"
(click)="$event.stopPropagation()"
>
<header class="modal-header">
<h3 id="modal-title">Demo Modal</h3>
<button
class="modal-close"
(click)="toggleModal()"
aria-label="Close modal"
>
×
</button>
</header>
<div class="modal-body">
<p>This modal demonstrates focus trapping. Tab navigation is contained within the modal.</p>
<input type="text" placeholder="Modal input" class="demo-input">
<ui-button variant="tonal">Modal Button</ui-button>
</div>
<footer class="modal-footer">
<ui-button (click)="toggleModal()">Close</ui-button>
<ui-button variant="filled">Save Changes</ui-button>
</footer>
</div>
</div>
}
</section>
<!-- Screen Reader Components -->
<section class="demo-section" aria-labelledby="sr-heading">
<h2 id="sr-heading">Screen Reader Components</h2>
<div class="sr-demo">
<h3>Screen Reader Only Text:</h3>
<p>
This text is visible.
<ui-screen-reader-only>This text is only for screen readers.</ui-screen-reader-only>
This text is visible again.
</p>
<h3>Focusable Screen Reader Text:</h3>
<ui-screen-reader-only type="focusable">
This text becomes visible when focused (try tabbing through the page).
</ui-screen-reader-only>
<h3>Dynamic Status Messages:</h3>
<div class="status-demo">
<ui-screen-reader-only type="status" statusType="success">
Form submitted successfully!
</ui-screen-reader-only>
<ui-screen-reader-only type="status" statusType="error">
Error: Please check your input.
</ui-screen-reader-only>
</div>
</div>
</section>
<!-- Keyboard Shortcuts Info -->
<section class="demo-section" aria-labelledby="shortcuts-heading">
<h2 id="shortcuts-heading">Keyboard Shortcuts</h2>
<div class="shortcuts-info">
<dl class="shortcuts-list">
<dt>Ctrl + Shift + A</dt>
<dd>Make announcement</dd>
<dt>Ctrl + M</dt>
<dd>Toggle modal</dd>
<dt>Tab / Shift + Tab</dt>
<dd>Navigate through interactive elements</dd>
<dt>Arrow Keys</dt>
<dd>Navigate within lists and grids (when focused)</dd>
<dt>Home / End</dt>
<dd>Jump to first/last item in navigable containers</dd>
<dt>Escape</dt>
<dd>Close modal or exit focus trap</dd>
</dl>
</div>
</section>
<!-- Footer -->
<footer class="demo-footer">
<p>
<ui-screen-reader-only>End of accessibility demo.</ui-screen-reader-only>
This demo showcases WCAG 2.1 Level AA compliant accessibility features using semantic design tokens.
</p>
</footer>
</div>

View File

@@ -0,0 +1,476 @@
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
.accessibility-demo {
padding: $semantic-spacing-layout-md;
max-width: 1200px;
margin: 0 auto;
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
.demo-header {
text-align: center;
margin-bottom: $semantic-spacing-component-lg * 2;
h1 {
color: #1a1a1a;
font-size: 2rem;
font-weight: 600;
margin-bottom: $semantic-spacing-component-lg;
}
p {
color: #666;
font-size: 1.125rem;
}
}
.demo-section {
margin-bottom: $semantic-spacing-component-lg * 2;
padding: $semantic-spacing-component-md $semantic-spacing-component-md;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e0e0e0;
h2 {
color: #1a1a1a;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: $semantic-spacing-component-lg;
border-bottom: 2px solid #007bff;
padding-bottom: $semantic-spacing-component-md / 2;
}
h3 {
color: #1a1a1a;
font-size: 1.25rem;
font-weight: 600;
margin: $semantic-spacing-component-lg 0 $semantic-spacing-component-lg / 2 0;
}
}
// Preferences Display
.preferences-display {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-sm;
padding: $semantic-spacing-component-md;
background: #fff;
border-radius: 4px;
border: 1px solid #e0e0e0;
p {
margin: 0;
font-size: 1rem;
color: #666;
}
}
// Demo Controls
.demo-controls {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-md;
}
.input-group {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-sm;
label {
font-weight: 500;
color: #1a1a1a;
font-size: 1rem;
}
}
.button-group {
display: flex;
gap: $semantic-spacing-component-sm;
flex-wrap: wrap;
align-items: center;
}
.demo-input {
padding: $semantic-spacing-component-md $semantic-spacing-component-md;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
color: #1a1a1a;
font-size: 1rem;
transition: border-color 0.15s ease-out;
&:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
border-color: #007bff;
}
&::placeholder {
color: #999;
}
}
.demo-select {
@extend .demo-input;
cursor: pointer;
}
.demo-textarea {
@extend .demo-input;
min-height: 80px;
resize: vertical;
}
// Focus Demo
.focus-demo {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-md;
}
// Focus Trap Demo
.trap-controls {
margin-bottom: $semantic-spacing-component-lg;
}
.focus-trap-container {
padding: $semantic-spacing-component-md * 1.5;
border: 2px dashed #e0e0e0;
border-radius: 8px;
background: #fff;
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-md;
transition: all 0.3s ease-out;
&.trap-enabled {
border-color: #007bff;
background: #f0f8ff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
h3 {
margin-top: 0;
color: #1a1a1a;
}
p {
color: #666;
margin: 0;
}
}
// Navigation Demo
.navigation-demo {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-lg;
}
.navigation-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
li {
padding: $semantic-spacing-component-md $semantic-spacing-component-md;
background: #fff;
border-bottom: 1px solid #e0e0e0;
cursor: pointer;
transition: all 0.15s ease-out;
color: #1a1a1a;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f5f5f5;
}
&:focus {
outline: 2px solid #007bff;
outline-offset: -2px;
background: #f0f8ff;
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
color: #999;
}
}
}
.navigation-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $semantic-spacing-component-sm;
padding: $semantic-spacing-component-md;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fff;
.grid-item {
padding: $semantic-spacing-component-md;
border: 1px solid #ccc;
border-radius: 4px;
background: #e9ecef;
color: #495057;
cursor: pointer;
transition: all 0.15s ease-out;
font-size: 0.875rem;
&:hover:not(:disabled) {
background: #dee2e6;
}
&:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
background: #f8f9fa;
color: #999;
}
}
}
// Modal Demo
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease-out;
}
.modal-content {
background: #fff;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
min-width: 400px;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
animation: slideIn 0.3s ease-out;
.modal-header {
padding: $semantic-spacing-component-md $semantic-spacing-component-md;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
color: #1a1a1a;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.15s ease-out;
&:hover {
background: #f5f5f5;
color: #1a1a1a;
}
&:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
}
}
.modal-body {
padding: $semantic-spacing-component-md * 1.5;
p {
color: #666;
margin-bottom: $semantic-spacing-component-lg;
}
.demo-input {
width: 100%;
margin-bottom: $semantic-spacing-component-lg;
}
}
.modal-footer {
padding: $semantic-spacing-component-md $semantic-spacing-component-md;
background: #f8f9fa;
border-top: 1px solid #e0e0e0;
display: flex;
gap: $semantic-spacing-component-sm;
justify-content: flex-end;
}
}
// Screen Reader Demo
.sr-demo {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-md;
p {
color: #666;
margin: $semantic-spacing-component-lg / 2 0;
}
}
.status-demo {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-sm;
}
// Keyboard Shortcuts
.shortcuts-info {
background: #f5f5f5;
padding: $semantic-spacing-component-md;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.shortcuts-list {
display: grid;
grid-template-columns: 200px 1fr;
gap: $semantic-spacing-component-sm $semantic-spacing-component-md;
margin: 0;
dt {
font-weight: 500;
color: #1a1a1a;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
text-align: center;
}
dd {
margin: 0;
color: #666;
display: flex;
align-items: center;
}
}
// Footer
.demo-footer {
text-align: center;
padding: $semantic-spacing-component-md * 2;
border-top: 1px solid #e0e0e0;
margin-top: $semantic-spacing-component-lg * 2;
p {
color: #666;
font-size: 0.875rem;
margin: 0;
}
}
// Animations
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Responsive Design
@media (max-width: 768px) {
.accessibility-demo {
padding: $semantic-spacing-component-md / 2;
}
.demo-section {
padding: $semantic-spacing-component-md / 2;
}
.button-group {
flex-direction: column;
align-items: stretch;
}
.navigation-grid {
grid-template-columns: repeat(2, 1fr);
}
.shortcuts-list {
grid-template-columns: 1fr;
gap: $semantic-spacing-component-sm;
dt {
text-align: left;
}
}
.modal-content {
min-width: 90vw;
margin: $semantic-spacing-component-md;
}
}
// High Contrast Mode Support
@media (prefers-contrast: high) {
.demo-section {
border-color: ButtonText;
}
.focus-trap-container.trap-enabled {
border-color: Highlight;
}
.navigation-list li:focus {
outline-color: Highlight;
}
.grid-item:focus {
outline-color: Highlight;
}
}
// Reduced Motion Support
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01s !important;
transition-duration: 0.01s !important;
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccessibilityDemoComponent } from './accessibility-demo.component';
describe('AccessibilityDemoComponent', () => {
let component: AccessibilityDemoComponent;
let fixture: ComponentFixture<AccessibilityDemoComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AccessibilityDemoComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AccessibilityDemoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,213 @@
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
// Import UI Accessibility components and services
import {
UiAccessibilityModule,
LiveAnnouncerService,
FocusMonitorService,
KeyboardManagerService,
HighContrastService,
A11yConfigService
} from 'ui-accessibility';
// Import UI Essentials components
import { ButtonComponent } from 'ui-essentials';
@Component({
selector: 'app-accessibility-demo',
imports: [
CommonModule,
FormsModule,
UiAccessibilityModule,
ButtonComponent
],
templateUrl: './accessibility-demo.component.html',
styleUrl: './accessibility-demo.component.scss'
})
export class AccessibilityDemoComponent implements OnInit, OnDestroy {
private liveAnnouncer = inject(LiveAnnouncerService);
private focusMonitor = inject(FocusMonitorService);
private keyboardManager = inject(KeyboardManagerService);
private highContrast = inject(HighContrastService);
private a11yConfig = inject(A11yConfigService);
@ViewChild('trapContainer') trapContainer!: ElementRef;
@ViewChild('monitoredInput') monitoredInput!: ElementRef;
// Demo state
trapEnabled = false;
showModal = false;
currentPreferences: any = {};
focusOrigin = '';
announcementText = 'Hello, this is a test announcement!';
selectedPoliteness: 'polite' | 'assertive' = 'polite';
// Navigation demo items
navigationItems = [
{ id: 1, label: 'Profile Settings', disabled: false },
{ id: 2, label: 'Account Details', disabled: false },
{ id: 3, label: 'Privacy Controls', disabled: true },
{ id: 4, label: 'Notifications', disabled: false },
{ id: 5, label: 'Security Options', disabled: false }
];
// Grid navigation items
gridItems = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
label: `Item ${i + 1}`,
disabled: i === 5 // Make item 6 disabled
}));
ngOnInit(): void {
this.setupKeyboardShortcuts();
this.setupFocusMonitoring();
this.getCurrentPreferences();
}
ngOnDestroy(): void {
this.keyboardManager.unregisterAll();
}
private setupKeyboardShortcuts(): void {
// Global shortcuts
this.keyboardManager.registerGlobalShortcut('announce', {
key: 'a',
ctrlKey: true,
shiftKey: true,
description: 'Make announcement',
handler: () => this.makeAnnouncement()
});
this.keyboardManager.registerGlobalShortcut('toggleModal', {
key: 'm',
ctrlKey: true,
description: 'Toggle modal',
handler: () => this.toggleModal()
});
}
private setupFocusMonitoring(): void {
// Monitor focus on the input field
setTimeout(() => {
if (this.monitoredInput) {
this.focusMonitor.monitor(this.monitoredInput).subscribe(focusEvent => {
this.focusOrigin = focusEvent.origin || 'none';
if (focusEvent.origin) {
this.liveAnnouncer.announce(
`Input focused via ${focusEvent.origin}`,
'polite'
);
}
});
}
}, 100);
}
private getCurrentPreferences(): void {
this.currentPreferences = this.highContrast.getCurrentPreferences();
}
// Demo actions
makeAnnouncement(): void {
this.liveAnnouncer.announce(this.announcementText, this.selectedPoliteness);
}
makeSequenceAnnouncement(): void {
const messages = [
'Starting process...',
'Processing data...',
'Validating information...',
'Process completed successfully!'
];
this.liveAnnouncer.announceSequence(messages, 'polite', 1500);
}
toggleFocusTrap(): void {
this.trapEnabled = !this.trapEnabled;
this.liveAnnouncer.announce(
`Focus trap ${this.trapEnabled ? 'enabled' : 'disabled'}`,
'assertive'
);
}
toggleModal(): void {
this.showModal = !this.showModal;
if (this.showModal) {
this.liveAnnouncer.announce('Modal opened', 'assertive');
} else {
this.liveAnnouncer.announce('Modal closed', 'assertive');
}
}
focusViaKeyboard(): void {
if (this.monitoredInput) {
this.focusMonitor.focusVia(this.monitoredInput, 'keyboard');
}
}
focusViaMouse(): void {
if (this.monitoredInput) {
this.focusMonitor.focusVia(this.monitoredInput, 'mouse');
}
}
focusViaProgram(): void {
if (this.monitoredInput) {
this.focusMonitor.focusVia(this.monitoredInput, 'program');
}
}
onNavigationChange(event: any): void {
this.liveAnnouncer.announce(
`Navigated to ${this.navigationItems[event.nextIndex]?.label}`,
'polite'
);
}
onGridNavigationChange(event: any): void {
this.liveAnnouncer.announce(
`Navigated to ${this.gridItems[event.nextIndex]?.label}`,
'polite'
);
}
announceError(): void {
this.liveAnnouncer.announce(
'Error: Please fix the required fields before continuing',
'assertive'
);
}
announceSuccess(): void {
this.liveAnnouncer.announce(
'Success: Your changes have been saved',
'polite'
);
}
announceLoading(): void {
this.liveAnnouncer.announce(
'Loading content, please wait...',
'polite'
);
}
refreshPreferences(): void {
this.getCurrentPreferences();
this.liveAnnouncer.announce('Accessibility preferences updated', 'polite');
}
simulateFormValidation(): void {
// Simulate a form validation scenario
this.liveAnnouncer.announce('Validating form...', 'polite');
setTimeout(() => {
this.liveAnnouncer.announce(
'Form validation complete. 2 errors found.',
'assertive'
);
}, 2000);
}
}

View File

@@ -0,0 +1,223 @@
import { Component, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiAnimationsService, AnimateDirective } from 'ui-animations';
@Component({
selector: 'app-animations-demo',
standalone: true,
imports: [CommonModule, AnimateDirective],
template: `
<div class="animations-demo">
<h2>UI Animations Demo</h2>
<!-- Entrance Animations -->
<section class="demo-section">
<h3>Entrance Animations</h3>
<div class="animation-grid">
<div class="demo-card animate-fade-in">
<h4>Fade In</h4>
<p>Basic fade in animation</p>
</div>
<div class="demo-card animate-fade-in-up animation-delay-200">
<h4>Fade In Up</h4>
<p>Fade in with upward movement</p>
</div>
<div class="demo-card animate-slide-in-up animation-delay-300">
<h4>Slide In Up</h4>
<p>Slide in from bottom</p>
</div>
<div class="demo-card animate-zoom-in animation-delay-500">
<h4>Zoom In</h4>
<p>Scale up animation</p>
</div>
</div>
</section>
<!-- Emphasis Animations -->
<section class="demo-section">
<h3>Emphasis Animations</h3>
<div class="animation-grid">
<button class="demo-button" (click)="animateElement('bounce')">
Bounce
</button>
<button class="demo-button" (click)="animateElement('shake')">
Shake
</button>
<button class="demo-button" (click)="animateElement('pulse')">
Pulse
</button>
<button class="demo-button" (click)="animateElement('wobble')">
Wobble
</button>
</div>
<div #animationTarget class="demo-target">
Click buttons above to animate me!
</div>
</section>
<!-- Directive Examples -->
<section class="demo-section">
<h3>Animation Directive Examples</h3>
<div class="animation-grid">
<div class="demo-card" uiAnimate="animate-fade-in-up" [animationTrigger]="'hover'">
<h4>Hover to Animate</h4>
<p>Uses directive with hover trigger</p>
</div>
<div class="demo-card" uiAnimate="animate-bounce" [animationTrigger]="'click'" [animationOnce]="true">
<h4>Click to Animate</h4>
<p>Uses directive with click trigger</p>
</div>
</div>
</section>
<!-- Service Examples -->
<section class="demo-section">
<h3>Animation Service Examples</h3>
<div class="service-controls">
<button (click)="serviceAnimate('animate-tada')">Tada</button>
<button (click)="serviceAnimate('animate-jello')">Jello</button>
<button (click)="serviceAnimate('animate-heartbeat')">Heartbeat</button>
<button (click)="serviceAnimate('animate-rubber-band')">Rubber Band</button>
</div>
<div #serviceTarget class="demo-target service-target">
Animation Service Target
</div>
</section>
</div>
`,
styles: [`
.animations-demo {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
margin-bottom: 3rem;
h3 {
margin-bottom: 1.5rem;
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 0.5rem;
}
}
.animation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.demo-card {
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
h4 {
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
}
p {
margin: 0;
opacity: 0.9;
}
&:hover {
transform: translateY(-2px);
}
}
.demo-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
background: #007bff;
color: white;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease;
&:hover {
background: #0056b3;
}
}
.demo-target {
padding: 2rem;
border: 2px dashed #007bff;
border-radius: 8px;
text-align: center;
font-size: 1.2rem;
font-weight: bold;
background: #f8f9fa;
margin-top: 1rem;
}
.service-target {
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
color: white;
border: none;
}
.service-controls {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.animation-grid {
grid-template-columns: 1fr;
}
.service-controls {
justify-content: center;
}
}
`]
})
export class AnimationsDemoComponent {
@ViewChild('animationTarget', { static: true }) animationTarget!: ElementRef<HTMLElement>;
@ViewChild('serviceTarget', { static: true }) serviceTarget!: ElementRef<HTMLElement>;
constructor(private animationService: UiAnimationsService) {}
animateElement(animationType: string) {
const element = this.animationTarget.nativeElement;
const animationClass = `animate-${animationType}`;
// Remove any existing animation classes first
element.className = element.className.replace(/animate-\w+/g, '');
// Add animation class
this.animationService.animateOnce(element, animationClass);
}
serviceAnimate(animationClass: string) {
const element = this.serviceTarget.nativeElement;
// Remove any existing animation classes first
element.className = element.className.replace(/animate-\w+/g, '');
// Add animation class with service
this.animationService.animateOnce(element, animationClass);
}
}

View File

@@ -0,0 +1,294 @@
@use 'ui-design-system/src/styles' as ui;
.backgrounds-demo {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
.demo-header {
text-align: center;
margin-bottom: 3rem;
h1 {
font-size: 3rem;
font-weight: 700;
color: var(--primary-900, #1e293b);
margin-bottom: 1rem;
}
p {
font-size: 1.2rem;
color: var(--neutral-600, #64748b);
}
}
.demo-section {
margin-bottom: 4rem;
h2 {
font-size: 2rem;
font-weight: 600;
color: var(--primary-800, #334155);
margin-bottom: 2rem;
border-bottom: 2px solid var(--primary-200, #e2e8f0);
padding-bottom: 0.5rem;
}
}
.example-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.demo-card {
padding: 2rem;
border-radius: 1rem;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
opacity: 0.9;
line-height: 1.5;
}
}
.pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.pattern-demo {
aspect-ratio: 1;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
.pattern-name {
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
text-transform: capitalize;
}
}
.gradient-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.gradient-demo {
aspect-ratio: 16/9;
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
&:hover {
transform: translateY(-3px);
}
}
.interactive-demo {
padding: 3rem;
border-radius: 1rem;
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
position: relative;
transition: all 0.5s ease;
.controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
justify-content: center;
}
h3 {
font-size: 2rem;
margin-bottom: 1rem;
}
p {
font-size: 1.1rem;
opacity: 0.9;
}
}
.fullscreen-controls {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
.action-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
&:active {
transform: translateY(0);
}
}
.preset-btn {
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.2);
color: var(--primary-700, #475569);
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.backgrounds-demo {
padding: 1rem;
.demo-header h1 {
font-size: 2rem;
}
.example-grid {
grid-template-columns: 1fr;
}
.pattern-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.gradient-grid {
grid-template-columns: 1fr;
}
.interactive-demo {
padding: 2rem;
.controls {
flex-direction: column;
width: 100%;
}
}
.fullscreen-controls {
flex-direction: column;
}
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.backgrounds-demo {
.demo-header h1 {
color: var(--neutral-100, #f8fafc);
}
.demo-section h2 {
color: var(--neutral-200, #e2e8f0);
border-bottom-color: var(--primary-700, #475569);
}
.preset-btn {
color: var(--neutral-200, #e2e8f0);
}
}
}
// High contrast mode
@media (prefers-contrast: high) {
.backgrounds-demo {
.demo-card,
.pattern-demo,
.gradient-demo,
.interactive-demo {
border: 2px solid rgba(0, 0, 0, 0.8);
}
}
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
.backgrounds-demo {
* {
transition: none !important;
animation: none !important;
}
}
}

View File

@@ -0,0 +1,307 @@
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
BackgroundDirective,
SolidBackgroundComponent,
GradientBackgroundComponent,
PatternBackgroundComponent,
ImageBackgroundComponent,
BackgroundService,
SolidBackgroundConfig,
LinearGradientConfig,
PatternConfig,
ImageBackgroundConfig
} from 'ui-backgrounds';
@Component({
selector: 'app-backgrounds-demo',
standalone: true,
imports: [
CommonModule,
BackgroundDirective,
SolidBackgroundComponent,
GradientBackgroundComponent,
PatternBackgroundComponent,
ImageBackgroundComponent
],
template: `
<div class="backgrounds-demo">
<div class="demo-header">
<h1>UI Backgrounds Demo</h1>
<p>Explore different background types and configurations</p>
</div>
<!-- Directive Examples -->
<section class="demo-section">
<h2>Background Directive Examples</h2>
<div class="example-grid">
<div class="demo-card" [uiBackground]="solidConfig()">
<h3>Solid Background</h3>
<p>Using the background directive with solid color</p>
</div>
<div class="demo-card" [uiBackground]="gradientConfig()">
<h3>Linear Gradient</h3>
<p>Beautiful gradient backgrounds</p>
</div>
<div class="demo-card" [uiBackground]="patternConfig()">
<h3>Pattern Background</h3>
<p>Geometric patterns</p>
</div>
</div>
</section>
<!-- Component Examples -->
<section class="demo-section">
<h2>Background Components</h2>
<div class="example-grid">
<ui-solid-background color="#4ade80" class="demo-card">
<h3>Solid Component</h3>
<p>Green background using component</p>
</ui-solid-background>
<ui-gradient-background
type="linear"
[colors]="['#8b5cf6', '#06b6d4']"
direction="45deg"
class="demo-card">
<h3>Gradient Component</h3>
<p>Purple to cyan gradient</p>
</ui-gradient-background>
<ui-pattern-background
pattern="dots"
primaryColor="#f59e0b"
secondaryColor="transparent"
[size]="30"
[opacity]="0.7"
class="demo-card">
<h3>Pattern Component</h3>
<p>Orange dots pattern</p>
</ui-pattern-background>
</div>
</section>
<!-- Pattern Showcase -->
<section class="demo-section">
<h2>Pattern Showcase</h2>
<div class="pattern-grid">
<div
*ngFor="let pattern of patterns"
class="pattern-demo"
[uiBackground]="getPatternConfig(pattern)">
<span class="pattern-name">{{ pattern }}</span>
</div>
</div>
</section>
<!-- Gradient Types -->
<section class="demo-section">
<h2>Gradient Types</h2>
<div class="gradient-grid">
<ui-gradient-background
type="linear"
[colors]="['#ff6b6b', '#4ecdc4', '#45b7d1']"
direction="to right"
class="gradient-demo">
<span>Linear Gradient</span>
</ui-gradient-background>
<ui-gradient-background
type="radial"
[colors]="['#667eea', '#764ba2']"
shape="circle"
class="gradient-demo">
<span>Radial Gradient</span>
</ui-gradient-background>
<ui-gradient-background
type="conic"
[colors]="['#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#ff6b6b']"
class="gradient-demo">
<span>Conic Gradient</span>
</ui-gradient-background>
</div>
</section>
<!-- Interactive Controls -->
<section class="demo-section">
<h2>Interactive Background</h2>
<div class="interactive-demo" [uiBackground]="interactiveConfig()">
<div class="controls">
<button
*ngFor="let config of presetConfigs"
(click)="setInteractiveConfig(config)"
class="preset-btn">
{{ config.name }}
</button>
</div>
<h3>Interactive Background Demo</h3>
<p>Click buttons to change the background</p>
</div>
</section>
<!-- Full Screen Examples -->
<section class="demo-section">
<h2>Full Screen Backgrounds</h2>
<div class="fullscreen-controls">
<button (click)="toggleFullScreenBackground()" class="action-btn">
{{ hasFullScreenBg() ? 'Remove' : 'Add' }} Full Screen Background
</button>
<button
*ngFor="let bg of fullScreenPresets"
(click)="setFullScreenBackground(bg)"
class="preset-btn">
{{ bg.name }}
</button>
</div>
</section>
</div>
`,
styleUrl: './backgrounds-demo.component.scss'
})
export class BackgroundsDemoComponent {
private readonly backgroundService = signal(new BackgroundService());
private fullScreenBackgroundId = signal<string | null>(null);
// Configuration signals
solidConfig = signal<SolidBackgroundConfig>({
type: 'solid',
color: '#3b82f6'
});
gradientConfig = signal<LinearGradientConfig>({
type: 'linear-gradient',
direction: 'to bottom right',
colors: [
{ color: '#ec4899' },
{ color: '#8b5cf6' }
]
});
patternConfig = signal<PatternConfig>({
type: 'pattern',
pattern: 'grid',
primaryColor: '#1f2937',
secondaryColor: 'transparent',
size: 20,
opacity: 0.3
});
interactiveConfig = signal(this.solidConfig());
// Pattern list for showcase
patterns = [
'dots', 'grid', 'stripes', 'diagonal-stripes',
'chevron', 'waves', 'circles', 'checkerboard'
];
// Preset configurations
presetConfigs = [
{
name: 'Ocean',
config: {
type: 'linear-gradient' as const,
direction: 'to bottom',
colors: [{ color: '#667eea' }, { color: '#764ba2' }]
}
},
{
name: 'Sunset',
config: {
type: 'linear-gradient' as const,
direction: 'to right',
colors: [{ color: '#ff6b6b' }, { color: '#feca57' }]
}
},
{
name: 'Forest',
config: {
type: 'solid' as const,
color: '#10b981'
}
},
{
name: 'Dots',
config: {
type: 'pattern' as const,
pattern: 'dots' as const,
primaryColor: '#6366f1',
secondaryColor: 'transparent',
size: 25,
opacity: 0.6
}
}
];
// Full screen presets
fullScreenPresets = [
{
name: 'Purple Mesh',
config: {
type: 'linear-gradient' as const,
direction: 'to bottom right',
colors: [{ color: '#667eea' }, { color: '#764ba2' }, { color: '#f093fb' }]
}
},
{
name: 'Geometric',
config: {
type: 'pattern' as const,
pattern: 'chevron' as const,
primaryColor: '#1f2937',
secondaryColor: 'transparent',
size: 40,
opacity: 0.1
}
}
];
getPatternConfig(pattern: string): PatternConfig {
return {
type: 'pattern',
pattern: pattern as any,
primaryColor: '#6366f1',
secondaryColor: 'transparent',
size: 25,
opacity: 0.4
};
}
setInteractiveConfig(preset: any) {
this.interactiveConfig.set(preset.config);
}
toggleFullScreenBackground() {
const currentId = this.fullScreenBackgroundId();
if (currentId) {
this.backgroundService().removeBackground(currentId);
this.fullScreenBackgroundId.set(null);
}
}
setFullScreenBackground(preset: any) {
// Remove existing full screen background
const currentId = this.fullScreenBackgroundId();
if (currentId) {
this.backgroundService().removeBackground(currentId);
}
// Add new full screen background
const id = this.backgroundService().applyFullScreenBackground(preset.config, {
zIndex: -1
});
this.fullScreenBackgroundId.set(id);
}
hasFullScreenBg() {
return this.fullScreenBackgroundId() !== null;
}
}

View File

@@ -0,0 +1,71 @@
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
.demo-container {
padding: $semantic-spacing-layout-section-sm;
max-width: 1200px;
margin: 0 auto;
h1 {
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-layout-section-sm;
border-bottom: 2px solid $semantic-color-border-primary;
padding-bottom: $semantic-spacing-component-md;
}
}
.demo-section {
margin-bottom: $semantic-spacing-layout-section-md;
h2 {
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-lg;
font-size: $base-typography-font-size-xl;
}
h3 {
color: $semantic-color-text-secondary;
margin: $semantic-spacing-component-lg 0 $semantic-spacing-component-md 0;
font-size: $base-typography-font-size-lg;
}
p {
margin-bottom: $semantic-spacing-component-md;
line-height: $base-typography-line-height-relaxed;
color: $semantic-color-text-primary;
}
}
.theme-controls {
display: flex;
gap: $semantic-spacing-component-sm;
margin-bottom: $semantic-spacing-component-lg;
flex-wrap: wrap;
}
.theme-btn {
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
border: $semantic-border-button-width $semantic-border-button-style $semantic-color-border-primary;
border-radius: $semantic-border-button-radius;
background: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
cursor: pointer;
font-size: $base-typography-font-size-sm;
text-transform: capitalize;
transition: all $semantic-duration-fast $semantic-easing-standard;
&:hover {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-brand-primary;
}
&.active {
background: $semantic-color-brand-primary;
color: $semantic-color-on-brand-primary;
border-color: $semantic-color-brand-primary;
}
&:focus-visible {
outline: $semantic-border-focus-width solid $semantic-color-focus;
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,214 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
CodeSnippetComponent,
InlineCodeComponent,
CodeBlockComponent,
CodeThemeService,
CodeTheme
} from 'ui-code-display';
@Component({
selector: 'app-code-display-demo',
standalone: true,
imports: [
CommonModule,
CodeSnippetComponent,
InlineCodeComponent,
CodeBlockComponent
],
template: `
<div class="demo-container">
<h1>Code Display Components Demo</h1>
<section class="demo-section">
<h2>Theme Selector</h2>
<div class="theme-controls">
@for (theme of themes; track theme) {
<button
(click)="setTheme(theme)"
[class.active]="currentTheme() === theme"
class="theme-btn">
{{ theme }}
</button>
}
</div>
</section>
<section class="demo-section">
<h2>Inline Code</h2>
<p>
Use <ui-inline-code code="console.log('hello')" language="javascript"></ui-inline-code>
to output to the console.
</p>
<p>
Install dependencies with <ui-inline-code code="npm install" language="bash" [copyable]="true"></ui-inline-code>
</p>
</section>
<section class="demo-section">
<h2>Code Snippet</h2>
<ui-code-snippet
[code]="typescriptCode"
language="typescript"
title="Component Example"
size="md"
[showLineNumbers]="true"
[copyable]="true"
[highlightLines]="[3, 5, '7-9']">
</ui-code-snippet>
</section>
<section class="demo-section">
<h2>Code Block</h2>
<ui-code-block
[code]="cssCode"
language="css"
variant="bordered"
[showLineNumbers]="true"
[copyable]="true">
</ui-code-block>
</section>
<section class="demo-section">
<h2>Different Sizes</h2>
<h3>Small</h3>
<ui-code-snippet
[code]="shortCode"
language="javascript"
size="sm"
title="Small Size">
</ui-code-snippet>
<h3>Medium (Default)</h3>
<ui-code-snippet
[code]="shortCode"
language="javascript"
size="md"
title="Medium Size">
</ui-code-snippet>
<h3>Large</h3>
<ui-code-snippet
[code]="shortCode"
language="javascript"
size="lg"
title="Large Size">
</ui-code-snippet>
</section>
<section class="demo-section">
<h2>Different Languages</h2>
<h3>Python</h3>
<ui-code-snippet
[code]="pythonCode"
language="python"
title="Python Example">
</ui-code-snippet>
<h3>JSON</h3>
<ui-code-snippet
[code]="jsonCode"
language="json"
title="Configuration">
</ui-code-snippet>
<h3>HTML</h3>
<ui-code-snippet
[code]="htmlCode"
language="html"
title="Template">
</ui-code-snippet>
</section>
</div>
`,
styleUrl: './code-display-demo.component.scss'
})
export class CodeDisplayDemoComponent {
constructor(private themeService: CodeThemeService) {
this.themes = this.themeService.getAvailableThemes();
this.currentTheme = this.themeService.theme;
}
readonly themes: any[];
readonly currentTheme: any;
readonly typescriptCode = `import { Component } from '@angular/core';
@Component({
selector: 'app-example',
template: \`
<div class="container">
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</div>
\`
})
export class ExampleComponent {
title = 'Hello World';
description = 'This is a demo component';
}`;
readonly cssCode = `.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
h1 {
color: var(--primary-color);
font-size: 2rem;
margin-bottom: 1rem;
}
p {
line-height: 1.6;
color: var(--text-color);
}
}`;
readonly shortCode = `const greeting = 'Hello, World!';
console.log(greeting);`;
readonly pythonCode = `def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# Generate first 10 Fibonacci numbers
for i in range(10):
print(f"F({i}) = {fibonacci(i)}")`;
readonly jsonCode = `{
"name": "ui-code-display",
"version": "1.0.0",
"description": "Code display components for Angular",
"dependencies": {
"@angular/core": "^19.0.0",
"prismjs": "^1.30.0"
},
"keywords": [
"angular",
"code",
"syntax-highlighting",
"prism"
]
}`;
readonly htmlCode = `<div class="card">
<header class="card-header">
<h2>Card Title</h2>
<button class="close-btn">&times;</button>
</header>
<div class="card-body">
<p>This is the card content.</p>
<a href="#" class="btn btn-primary">Action</a>
</div>
</div>`;
setTheme(theme: CodeTheme): void {
this.themeService.setTheme(theme);
}
}

View File

@@ -0,0 +1,741 @@
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
// Import all utilities from ui-data-utils
import {
// Types
SortConfig, FilterConfig, PaginationConfig,
// Sorting
sortBy, sortByMultiple, createComparator,
// Filtering
filterBy, filterByMultiple, searchFilter,
// Pagination
paginate, calculatePages, getPaginationRange,
// Transformation
groupBy, aggregate, pluck, flatten, pivot, unique, frequency
} from 'ui-data-utils';
interface SampleData {
id: number;
name: string;
department: string;
salary: number;
hireDate: string;
active: boolean;
skills: string[];
}
@Component({
selector: 'ui-data-utils-demo',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div style="padding: 2rem;">
<h2>UI Data Utils Demo</h2>
<p>Comprehensive demonstration of data manipulation utilities</p>
<!-- Sample Data Display -->
<section style="margin-bottom: 3rem;">
<h3>Sample Employee Data ({{ sampleData.length }} records)</h3>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
<h4>Raw Data Preview:</h4>
<pre style="max-height: 200px; overflow-y: auto;">{{ getSampleDataPreview() }}</pre>
</div>
</section>
<!-- Sorting Utilities Demo -->
<section style="margin-bottom: 3rem;">
<h3>🔄 Sorting Utilities</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<!-- Single Sort -->
<div>
<h4>Single Property Sort</h4>
<div style="margin-bottom: 1rem;">
<label>Sort by: </label>
<select [(ngModel)]="sortField" (ngModelChange)="applySingleSort()">
<option value="name">Name</option>
<option value="department">Department</option>
<option value="salary">Salary</option>
<option value="hireDate">Hire Date</option>
</select>
<label style="margin-left: 1rem;">
<input type="checkbox" [(ngModel)]="sortDesc" (ngModelChange)="applySingleSort()">
Descending
</label>
</div>
<div style="background: #e3f2fd; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getSortedDataPreview() }}</pre>
</div>
</div>
<!-- Multi-column Sort -->
<div>
<h4>Multi-column Sort</h4>
<p style="font-size: 0.9em; color: #666;">Department → Salary → Name</p>
<div style="background: #e8f5e8; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getMultiSortedDataPreview() }}</pre>
</div>
</div>
</div>
</section>
<!-- Filtering Utilities Demo -->
<section style="margin-bottom: 3rem;">
<h3>🔍 Filtering Utilities</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<!-- Search Filter -->
<div>
<h4>Search Filter</h4>
<input
type="text"
placeholder="Search in name, department..."
[(ngModel)]="searchTerm"
(ngModelChange)="applySearchFilter()"
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem;"
>
<div style="background: #fff3cd; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getSearchFilteredDataPreview() }}</pre>
</div>
</div>
<!-- Property Filter -->
<div>
<h4>Property Filters</h4>
<div style="margin-bottom: 1rem;">
<label>Department: </label>
<select [(ngModel)]="filterDepartment" (ngModelChange)="applyPropertyFilter()">
<option value="">All Departments</option>
<option value="Engineering">Engineering</option>
<option value="Marketing">Marketing</option>
<option value="Sales">Sales</option>
<option value="HR">HR</option>
</select>
</div>
<div style="margin-bottom: 1rem;">
<label>Min Salary: </label>
<input
type="number"
[(ngModel)]="minSalary"
(ngModelChange)="applyPropertyFilter()"
placeholder="50000"
style="width: 120px; padding: 0.25rem;"
>
</div>
<div style="background: #f3e5f5; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getPropertyFilteredDataPreview() }}</pre>
</div>
</div>
</div>
</section>
<!-- Pagination Demo -->
<section style="margin-bottom: 3rem;">
<h3>📄 Pagination Utilities</h3>
<div>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<label>Page Size: </label>
<select [(ngModel)]="pageSize" (ngModelChange)="updatePagination()">
<option [value]="3">3</option>
<option [value]="5">5</option>
<option [value]="7">7</option>
<option [value]="10">10</option>
</select>
<label>Page: </label>
<input
type="number"
[(ngModel)]="currentPage"
(ngModelChange)="updatePagination()"
[min]="1"
[max]="paginationResult().totalPages"
style="width: 60px; padding: 0.25rem;"
>
<span>of {{ paginationResult().totalPages }}</span>
<button
(click)="previousPage()"
[disabled]="!paginationResult().hasPrevious"
style="padding: 0.25rem 0.5rem;"
>
Previous
</button>
<button
(click)="nextPage()"
[disabled]="!paginationResult().hasNext"
style="padding: 0.25rem 0.5rem;"
>
Next
</button>
</div>
<div style="margin-bottom: 1rem;">
<strong>Page Navigation: </strong>
@for (page of pageRange(); track page) {
@if (page === 'ellipsis') {
<span style="margin: 0 0.5rem;">...</span>
} @else {
<button
(click)="goToPage(page)"
[style.font-weight]="page === currentPage() ? 'bold' : 'normal'"
[style.background]="page === currentPage() ? '#007bff' : '#f8f9fa'"
[style.color]="page === currentPage() ? 'white' : 'black'"
style="padding: 0.25rem 0.5rem; margin: 0 0.125rem; border: 1px solid #ccc; cursor: pointer;"
>
{{ page }}
</button>
}
}
</div>
<div style="background: #e1f5fe; padding: 1rem; border-radius: 4px;">
<p><strong>Showing:</strong> {{ getItemRangeText() }}</p>
<pre style="max-height: 150px; overflow-y: auto;">{{ getPaginatedDataPreview() }}</pre>
</div>
</div>
</section>
<!-- Transformation Utilities Demo -->
<section style="margin-bottom: 3rem;">
<h3>🔄 Transformation Utilities</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<!-- Group By -->
<div>
<h4>Group By Department</h4>
<div style="background: #f1f8e9; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getGroupedDataPreview() }}</pre>
</div>
</div>
<!-- Aggregation -->
<div>
<h4>Salary Statistics by Department</h4>
<div style="background: #fce4ec; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getAggregationPreview() }}</pre>
</div>
</div>
<!-- Pluck -->
<div>
<h4>Extract Names & Salaries</h4>
<div style="background: #f3e5f5; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getPluckedDataPreview() }}</pre>
</div>
</div>
<!-- Pivot Table -->
<div>
<h4>Pivot: Department vs Active Status</h4>
<div style="background: #fff3e0; padding: 1rem; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<pre>{{ getPivotDataPreview() }}</pre>
</div>
</div>
</div>
</section>
<!-- Combined Operations Demo -->
<section style="margin-bottom: 3rem;">
<h3>🔄 Combined Operations</h3>
<p>Demonstrate chaining multiple operations: Filter → Sort → Paginate</p>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
<label>
Filter Active:
<select [(ngModel)]="combinedActiveFilter" (ngModelChange)="updateCombinedDemo()">
<option value="">All</option>
<option value="true">Active Only</option>
<option value="false">Inactive Only</option>
</select>
</label>
<label>
Sort By:
<select [(ngModel)]="combinedSortField" (ngModelChange)="updateCombinedDemo()">
<option value="name">Name</option>
<option value="salary">Salary</option>
<option value="hireDate">Hire Date</option>
</select>
</label>
<label>
<input type="checkbox" [(ngModel)]="combinedSortDesc" (ngModelChange)="updateCombinedDemo()">
Descending
</label>
</div>
<div style="background: #e8f5e8; padding: 1rem; border-radius: 4px;">
<p><strong>Pipeline:</strong>
{{ sampleData.length }} records →
{{ combinedFiltered().length }} filtered →
{{ combinedSorted().length }} sorted →
{{ combinedPaginated().data.length }} on page {{ combinedPaginated().page }}
</p>
<pre style="max-height: 200px; overflow-y: auto;">{{ getCombinedResultPreview() }}</pre>
</div>
</section>
<!-- Performance Metrics -->
<section style="margin-bottom: 3rem;">
<h3>📊 Performance & Statistics</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; text-align: center;">
<h4>Total Records</h4>
<div style="font-size: 2rem; font-weight: bold; color: #007bff;">{{ sampleData.length }}</div>
</div>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; text-align: center;">
<h4>Departments</h4>
<div style="font-size: 2rem; font-weight: bold; color: #28a745;">{{ getDepartmentCount() }}</div>
</div>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; text-align: center;">
<h4>Avg Salary</h4>
<div style="font-size: 2rem; font-weight: bold; color: #ffc107;">{{ getAverageSalary() }}</div>
</div>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; text-align: center;">
<h4>Active Employees</h4>
<div style="font-size: 2rem; font-weight: bold; color: #17a2b8;">{{ getActiveCount() }}%</div>
</div>
</div>
</section>
<!-- Code Examples -->
<section style="margin-bottom: 3rem;">
<h3>💻 Code Examples</h3>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px;">
<h4>Sorting Examples:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>// Single sort
const sorted = sortBy(data, {{ '{' }} key: 'salary', direction: 'desc', dataType: 'number' {{ '}' }});
// Multi-column sort
const multiSorted = sortByMultiple(data, {{ '{' }}
sorts: [
{{ '{' }} key: 'department', direction: 'asc', dataType: 'string' {{ '}' }},
{{ '{' }} key: 'salary', direction: 'desc', dataType: 'number' {{ '}' }}
]
{{ '}' }});</code></pre>
<h4>Filtering Examples:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>// Property filter
const filtered = filterBy(data, {{ '{' }}
key: 'salary',
operator: 'greater_than',
value: 50000,
dataType: 'number'
{{ '}' }});
// Search across multiple properties
const searched = searchFilter(data, 'engineer', ['name', 'department']);</code></pre>
<h4>Pagination Examples:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>// Paginate data
const paginatedResult = paginate(data, {{ '{' }} page: 1, pageSize: 10 {{ '}' }});
// Get page range for UI
const range = getPaginationRange(currentPage, totalPages, 7);</code></pre>
<h4>Transformation Examples:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>// Group by property
const groups = groupBy(data, 'department');
// Aggregate values
const stats = aggregate(data, 'salary', ['sum', 'avg', 'min', 'max']);
// Extract specific properties
const names = pluck(data, ['name', 'salary']);</code></pre>
</div>
</section>
<!-- Library Info -->
<section style="margin-bottom: 3rem;">
<h3>📚 Library Information</h3>
<div style="background: #e3f2fd; padding: 1.5rem; border-radius: 4px;">
<h4>UI Data Utils Features:</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;">
<div>
<h5>🔄 Sorting</h5>
<ul style="margin: 0; padding-left: 1.5rem;">
<li>Single & multi-column sorting</li>
<li>Type-aware comparisons</li>
<li>Stable sort algorithms</li>
<li>Custom comparator functions</li>
</ul>
</div>
<div>
<h5>🔍 Filtering</h5>
<ul style="margin: 0; padding-left: 1.5rem;">
<li>Multiple filter operators</li>
<li>Search across properties</li>
<li>Complex filter combinations</li>
<li>Type-specific filtering</li>
</ul>
</div>
<div>
<h5>📄 Pagination</h5>
<ul style="margin: 0; padding-left: 1.5rem;">
<li>Client-side pagination</li>
<li>Page range calculations</li>
<li>Pagination metadata</li>
<li>Navigation utilities</li>
</ul>
</div>
<div>
<h5>🔄 Transformation</h5>
<ul style="margin: 0; padding-left: 1.5rem;">
<li>Group by operations</li>
<li>Data aggregations</li>
<li>Property extraction</li>
<li>Pivot tables</li>
</ul>
</div>
</div>
</div>
</section>
</div>
`,
styles: [`
h2 {
color: hsl(279, 14%, 11%);
font-size: 2rem;
margin-bottom: 1rem;
border-bottom: 2px solid hsl(258, 100%, 47%);
padding-bottom: 0.5rem;
}
h3 {
color: hsl(279, 14%, 25%);
font-size: 1.5rem;
margin-bottom: 1rem;
}
h4 {
color: hsl(279, 14%, 35%);
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
h5 {
color: hsl(279, 14%, 45%);
font-size: 1rem;
margin-bottom: 0.5rem;
}
section {
border: 1px solid #e9ecef;
padding: 1.5rem;
border-radius: 8px;
background: #ffffff;
}
pre {
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85rem;
line-height: 1.4;
}
code {
color: hsl(279, 14%, 15%);
}
button:hover:not(:disabled) {
opacity: 0.8;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input, select {
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.25rem;
}
label {
font-weight: 500;
}
`]
})
export class DataUtilsDemoComponent {
// Sample data
sampleData: SampleData[] = [
{ id: 1, name: 'Alice Johnson', department: 'Engineering', salary: 95000, hireDate: '2022-01-15', active: true, skills: ['JavaScript', 'Angular', 'TypeScript'] },
{ id: 2, name: 'Bob Smith', department: 'Marketing', salary: 65000, hireDate: '2021-03-22', active: true, skills: ['SEO', 'Content Marketing'] },
{ id: 3, name: 'Charlie Brown', department: 'Engineering', salary: 87000, hireDate: '2023-02-10', active: false, skills: ['React', 'Node.js'] },
{ id: 4, name: 'Diana Davis', department: 'Sales', salary: 72000, hireDate: '2020-11-05', active: true, skills: ['CRM', 'Negotiation'] },
{ id: 5, name: 'Eve Wilson', department: 'Engineering', salary: 102000, hireDate: '2021-08-18', active: true, skills: ['Python', 'Machine Learning'] },
{ id: 6, name: 'Frank Miller', department: 'HR', salary: 58000, hireDate: '2022-05-30', active: false, skills: ['Recruiting', 'Employee Relations'] },
{ id: 7, name: 'Grace Lee', department: 'Marketing', salary: 69000, hireDate: '2023-01-12', active: true, skills: ['Brand Management', 'Social Media'] },
{ id: 8, name: 'Henry Taylor', department: 'Sales', salary: 78000, hireDate: '2021-12-03', active: true, skills: ['Sales Strategy', 'Lead Generation'] },
{ id: 9, name: 'Ivy Chen', department: 'Engineering', salary: 91000, hireDate: '2022-09-14', active: true, skills: ['Vue.js', 'GraphQL'] },
{ id: 10, name: 'Jack White', department: 'HR', salary: 62000, hireDate: '2020-04-25', active: false, skills: ['Payroll', 'Benefits Administration'] }
];
// Sorting controls
sortField = signal<keyof SampleData>('name');
sortDesc = signal<boolean>(false);
// Filtering controls
searchTerm = signal<string>('');
filterDepartment = signal<string>('');
minSalary = signal<number | null>(null);
// Pagination controls
currentPage = signal<number>(1);
pageSize = signal<number>(5);
// Combined demo controls
combinedActiveFilter = signal<string>('');
combinedSortField = signal<keyof SampleData>('name');
combinedSortDesc = signal<boolean>(false);
// Computed data
sortedData = computed(() => {
const config: SortConfig<SampleData> = {
key: this.sortField(),
direction: this.sortDesc() ? 'desc' : 'asc',
dataType: this.getDataType(this.sortField())
};
return sortBy(this.sampleData, config);
});
multiSortedData = computed(() => {
return sortByMultiple(this.sampleData, {
sorts: [
{ key: 'department', direction: 'asc', dataType: 'string' },
{ key: 'salary', direction: 'desc', dataType: 'number' },
{ key: 'name', direction: 'asc', dataType: 'string' }
]
});
});
searchFilteredData = computed(() => {
if (!this.searchTerm()) return this.sampleData;
return searchFilter(this.sampleData, this.searchTerm(), ['name', 'department']);
});
propertyFilteredData = computed(() => {
let filtered = this.sampleData;
const filters: FilterConfig<SampleData>[] = [];
if (this.filterDepartment()) {
filters.push({
key: 'department',
operator: 'equals',
value: this.filterDepartment(),
dataType: 'string'
});
}
if (this.minSalary() !== null) {
filters.push({
key: 'salary',
operator: 'greater_than_equal',
value: this.minSalary(),
dataType: 'number'
});
}
return filters.length > 0 ? filterByMultiple(filtered, filters) : filtered;
});
paginationResult = computed(() => {
return paginate(this.sampleData, {
page: this.currentPage(),
pageSize: this.pageSize()
});
});
pageRange = computed(() => {
return getPaginationRange(this.currentPage(), this.paginationResult().totalPages, 7);
});
// Combined operations
combinedFiltered = computed(() => {
if (!this.combinedActiveFilter()) return this.sampleData;
return filterBy(this.sampleData, {
key: 'active',
operator: 'equals',
value: this.combinedActiveFilter() === 'true',
dataType: 'boolean'
});
});
combinedSorted = computed(() => {
return sortBy(this.combinedFiltered(), {
key: this.combinedSortField(),
direction: this.combinedSortDesc() ? 'desc' : 'asc',
dataType: this.getDataType(this.combinedSortField())
});
});
combinedPaginated = computed(() => {
return paginate(this.combinedSorted(), { page: 1, pageSize: 5 });
});
// Helper methods
private getDataType(key: keyof SampleData): 'string' | 'number' | 'date' | 'boolean' {
switch (key) {
case 'salary': case 'id': return 'number';
case 'hireDate': return 'date';
case 'active': return 'boolean';
default: return 'string';
}
}
getSampleDataPreview(): string {
return JSON.stringify(this.sampleData.slice(0, 3), null, 2);
}
getSortedDataPreview(): string {
return JSON.stringify(this.sortedData().slice(0, 3).map(item => ({ name: item.name, [this.sortField()]: item[this.sortField()] })), null, 2);
}
getMultiSortedDataPreview(): string {
return JSON.stringify(this.multiSortedData().slice(0, 4).map(item => ({
name: item.name,
department: item.department,
salary: item.salary
})), null, 2);
}
getSearchFilteredDataPreview(): string {
return JSON.stringify(this.searchFilteredData().slice(0, 3).map(item => ({
name: item.name,
department: item.department
})), null, 2);
}
getPropertyFilteredDataPreview(): string {
return JSON.stringify(this.propertyFilteredData().slice(0, 3).map(item => ({
name: item.name,
department: item.department,
salary: item.salary
})), null, 2);
}
getPaginatedDataPreview(): string {
return JSON.stringify(this.paginationResult().data.map(item => ({
name: item.name,
department: item.department
})), null, 2);
}
getGroupedDataPreview(): string {
const grouped = groupBy(this.sampleData, 'department');
return JSON.stringify(grouped.map(group => ({
department: group.key,
count: group.count,
employees: group.items.map(emp => emp.name)
})), null, 2);
}
getAggregationPreview(): string {
const grouped = groupBy(this.sampleData, 'department');
const result = grouped.map(group => ({
department: group.key,
...aggregate(group.items, 'salary', ['sum', 'avg', 'min', 'max', 'count'])
}));
return JSON.stringify(result, null, 2);
}
getPluckedDataPreview(): string {
const plucked = pluck(this.sampleData, ['name', 'salary']);
return JSON.stringify(plucked.slice(0, 5), null, 2);
}
getPivotDataPreview(): string {
const pivotData = this.sampleData.map(emp => ({
department: emp.department,
status: emp.active ? 'Active' : 'Inactive',
count: 1
}));
const pivotResult = pivot(pivotData, 'department', 'status', 'count', 'sum');
return JSON.stringify(pivotResult, null, 2);
}
getCombinedResultPreview(): string {
return JSON.stringify(this.combinedPaginated().data.map(item => ({
name: item.name,
department: item.department,
salary: item.salary,
active: item.active
})), null, 2);
}
getItemRangeText(): string {
const result = this.paginationResult();
if (result.totalItems === 0) return '0 of 0';
return `${result.startIndex + 1}-${result.endIndex + 1} of ${result.totalItems}`;
}
getDepartmentCount(): number {
return unique(this.sampleData, 'department').length;
}
getAverageSalary(): string {
const avg = aggregate(this.sampleData, 'salary', ['avg']);
return '$' + Math.round(avg['avg'] || 0).toLocaleString();
}
getActiveCount(): number {
const activeCount = this.sampleData.filter(emp => emp.active).length;
return Math.round((activeCount / this.sampleData.length) * 100);
}
// Event handlers
applySingleSort(): void {
// Triggers computed update
}
applySearchFilter(): void {
// Triggers computed update
}
applyPropertyFilter(): void {
// Triggers computed update
}
updatePagination(): void {
// Ensure page is within bounds
const maxPage = this.paginationResult().totalPages;
if (this.currentPage() > maxPage) {
this.currentPage.set(maxPage || 1);
}
}
previousPage(): void {
if (this.paginationResult().hasPrevious) {
this.currentPage.update(page => page - 1);
}
}
nextPage(): void {
if (this.paginationResult().hasNext) {
this.currentPage.update(page => page + 1);
}
}
goToPage(page: number | string): void {
if (typeof page === 'number') {
this.currentPage.set(page);
}
}
updateCombinedDemo(): void {
// Triggers computed update
}
}

View File

@@ -9,6 +9,7 @@ import { TableDemoComponent } from './table-demo/table-demo.component';
import { BadgeDemoComponent } from './badge-demo/badge-demo.component';
import { MenuDemoComponent } from './menu-demo/menu-demo.component';
import { InputDemoComponent } from './input-demo/input-demo.component';
import { KanbanBoardDemoComponent } from './kanban-board-demo/kanban-board-demo.component';
import { RadioDemoComponent } from './radio-demo/radio-demo.component';
import { CheckboxDemoComponent } from './checkbox-demo/checkbox-demo.component';
import { SearchDemoComponent } from './search-demo/search-demo.component';
@@ -77,6 +78,20 @@ import { GridContainerDemoComponent } from './grid-container-demo/grid-container
import { FeedLayoutDemoComponent } from './feed-layout-demo/feed-layout-demo.component';
import { ListDetailLayoutDemoComponent } from './list-detail-layout-demo/list-detail-layout-demo.component';
import { SupportingPaneLayoutDemoComponent } from './supporting-pane-layout-demo/supporting-pane-layout-demo.component';
import { MasonryDemoComponent } from './masonry-demo/masonry-demo.component';
import { InfiniteScrollContainerDemoComponent } from './infinite-scroll-container-demo/infinite-scroll-container-demo.component';
import { StickyLayoutDemoComponent } from './sticky-layout-demo/sticky-layout-demo.component';
import { SplitViewDemoComponent } from './split-view-demo/split-view-demo.component';
import { GalleryGridDemoComponent } from './gallery-grid-demo/gallery-grid-demo.component';
import { AnimationsDemoComponent } from './animations-demo/animations-demo.component';
import { AccessibilityDemoComponent } from './accessibility-demo/accessibility-demo.component';
import { SelectDemoComponent } from './select-demo/select-demo.component';
import { TextareaDemoComponent } from './textarea-demo/textarea-demo.component';
import { DataUtilsDemoComponent } from './data-utils-demo/data-utils-demo.component';
import { HclStudioDemoComponent } from './hcl-studio-demo/hcl-studio-demo.component';
import { FontManagerDemoComponent } from './font-manager-demo/font-manager-demo.component';
import { CodeDisplayDemoComponent } from './code-display-demo/code-display-demo.component';
import { BackgroundsDemoComponent } from './backgrounds-demo/backgrounds-demo.component';
@Component({
@@ -128,6 +143,10 @@ import { SupportingPaneLayoutDemoComponent } from './supporting-pane-layout-demo
@case ("input") {
<ui-input-demo></ui-input-demo>
}
@case ("kanban-board") {
<ui-kanban-board-demo></ui-kanban-board-demo>
}
@case ("radio") {
<ui-radio-demo></ui-radio-demo>
}
@@ -140,6 +159,14 @@ import { SupportingPaneLayoutDemoComponent } from './supporting-pane-layout-demo
<ui-search-demo></ui-search-demo>
}
@case ("select") {
<ui-select-demo></ui-select-demo>
}
@case ("textarea") {
<ui-textarea-demo></ui-textarea-demo>
}
@case ("switch") {
<ui-switch-demo></ui-switch-demo>
}
@@ -360,6 +387,10 @@ import { SupportingPaneLayoutDemoComponent } from './supporting-pane-layout-demo
<ui-scroll-container-demo></ui-scroll-container-demo>
}
@case ("infinite-scroll-container") {
<ui-infinite-scroll-container-demo></ui-infinite-scroll-container-demo>
}
@case ("tabs-container") {
<ui-tabs-container-demo></ui-tabs-container-demo>
}
@@ -384,12 +415,56 @@ import { SupportingPaneLayoutDemoComponent } from './supporting-pane-layout-demo
<ui-supporting-pane-layout-demo></ui-supporting-pane-layout-demo>
}
@case ("masonry") {
<ui-masonry-demo></ui-masonry-demo>
}
@case ("sticky-layout") {
<ui-sticky-layout-demo></ui-sticky-layout-demo>
}
@case ("split-view") {
<ui-split-view-demo></ui-split-view-demo>
}
@case ("gallery-grid") {
<ui-gallery-grid-demo></ui-gallery-grid-demo>
}
@case ("animations") {
<app-animations-demo></app-animations-demo>
}
@case ("accessibility") {
<app-accessibility-demo></app-accessibility-demo>
}
@case ("data-utils") {
<ui-data-utils-demo></ui-data-utils-demo>
}
@case ("hcl-studio") {
<app-hcl-studio-demo></app-hcl-studio-demo>
}
@case ("font-manager") {
<app-font-manager-demo></app-font-manager-demo>
}
@case ("code-display") {
<app-code-display-demo></app-code-display-demo>
}
@case ("backgrounds") {
<app-backgrounds-demo></app-backgrounds-demo>
}
}
`,
imports: [AvatarDemoComponent, ButtonDemoComponent, IconButtonDemoComponent, CardDemoComponent,
ChipDemoComponent, TableDemoComponent, BadgeDemoComponent,
MenuDemoComponent, InputDemoComponent,
MenuDemoComponent, InputDemoComponent, KanbanBoardDemoComponent,
RadioDemoComponent, CheckboxDemoComponent,
SearchDemoComponent, SwitchDemoComponent, ProgressDemoComponent,
AppbarDemoComponent, BottomNavigationDemoComponent, FontAwesomeDemoComponent, ImageContainerDemoComponent,
@@ -399,7 +474,7 @@ import { SupportingPaneLayoutDemoComponent } from './supporting-pane-layout-demo
SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent,
AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent,
ProgressCircleDemoComponent, RangeSliderDemoComponent, ColorPickerDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent,
PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent, CommandPaletteDemoComponent, FloatingToolbarDemoComponent, TransferListDemoComponent, TagInputDemoComponent, StackDemoComponent, BoxDemoComponent, CenterDemoComponent, AspectRatioDemoComponent, BentoGridDemoComponent, BreakpointContainerDemoComponent, SectionDemoComponent, FlexDemoComponent, ColumnDemoComponent, SidebarLayoutDemoComponent, ScrollContainerDemoComponent, TabsContainerDemoComponent, DashboardShellDemoComponent, GridContainerDemoComponent, FeedLayoutDemoComponent, ListDetailLayoutDemoComponent, SupportingPaneLayoutDemoComponent]
PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent, CommandPaletteDemoComponent, FloatingToolbarDemoComponent, TransferListDemoComponent, TagInputDemoComponent, StackDemoComponent, BoxDemoComponent, CenterDemoComponent, AspectRatioDemoComponent, BentoGridDemoComponent, BreakpointContainerDemoComponent, SectionDemoComponent, FlexDemoComponent, ColumnDemoComponent, SidebarLayoutDemoComponent, ScrollContainerDemoComponent, InfiniteScrollContainerDemoComponent, TabsContainerDemoComponent, DashboardShellDemoComponent, GridContainerDemoComponent, FeedLayoutDemoComponent, ListDetailLayoutDemoComponent, SupportingPaneLayoutDemoComponent, MasonryDemoComponent, StickyLayoutDemoComponent, SplitViewDemoComponent, GalleryGridDemoComponent, AnimationsDemoComponent, AccessibilityDemoComponent, SelectDemoComponent, TextareaDemoComponent, DataUtilsDemoComponent, HclStudioDemoComponent, FontManagerDemoComponent, CodeDisplayDemoComponent, BackgroundsDemoComponent]
})

View File

@@ -0,0 +1,178 @@
<div class="demo-container">
<h1>UI Font Manager Demo</h1>
<p class="demo-description">
Dynamically load Google Fonts and switch between font themes with smooth transitions.
Select a font preset to apply it globally to the entire application.
</p>
<!-- Font Preset Selection -->
<section class="demo-section">
<h2>Font Preset Selection</h2>
<p class="preset-description">
Choose from curated font combinations. The selected preset will be applied to the entire application.
</p>
<div class="preset-controls">
<div class="preset-selector">
<label for="preset-select">Choose Font Preset:</label>
<select
id="preset-select"
[value]="selectedPreset"
(change)="onPresetChange($event)">
<option value="">Select a preset...</option>
<option *ngFor="let preset of fontPresets" [value]="preset.key">
{{preset.name}} - {{preset.description}}
</option>
</select>
</div>
<div class="transition-selector" *ngIf="selectedPreset">
<label for="transition-select">Transition Effect:</label>
<select id="transition-select" [(ngModel)]="selectedTransition">
<option value="fade">Fade</option>
<option value="scale">Scale</option>
<option value="typewriter">Typewriter</option>
</select>
<button
class="apply-btn"
(click)="applyPresetWithTransition()">
Apply with {{selectedTransition | titlecase}} Effect
</button>
</div>
</div>
<!-- Current Preset Display -->
<div class="current-preset" *ngIf="currentPreset">
<h4>Currently Applied: {{currentPreset.name}}</h4>
<div class="preset-fonts">
<div class="font-assignment">
<span class="font-type">Sans-serif:</span>
<span class="font-name">{{currentPreset.fonts.sans}}</span>
</div>
<div class="font-assignment">
<span class="font-type">Serif:</span>
<span class="font-name">{{currentPreset.fonts.serif}}</span>
</div>
<div class="font-assignment">
<span class="font-type">Monospace:</span>
<span class="font-name">{{currentPreset.fonts.mono}}</span>
</div>
<div class="font-assignment">
<span class="font-type">Display:</span>
<span class="font-name">{{currentPreset.fonts.display}}</span>
</div>
</div>
</div>
</section>
<!-- Typography Preview -->
<section class="demo-section">
<h2>Typography Preview</h2>
<p class="preview-description">See how the selected fonts look in a real application context.</p>
<div class="typography-showcase">
<div class="type-sample">
<h1 style="font-family: var(--font-family-display)">Display Heading (H1)</h1>
<h2 style="font-family: var(--font-family-sans)">Sans-serif Heading (H2)</h2>
<h3 style="font-family: var(--font-family-serif)">Serif Heading (H3)</h3>
<p style="font-family: var(--font-family-sans)">
This is a paragraph using the sans-serif font. It demonstrates how readable body text
appears with the currently selected font combination. Lorem ipsum dolor sit amet,
consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
<blockquote style="font-family: var(--font-family-serif)">
"This is a quote using the serif font, which often provides better readability
for longer text passages and adds elegance to quoted content."
</blockquote>
<pre><code style="font-family: var(--font-family-mono)">// Code example with monospace font
function applyFontTheme(theme) {
document.documentElement.style.setProperty('--font-family-sans', theme.sans);
document.documentElement.style.setProperty('--font-family-serif', theme.serif);
document.documentElement.style.setProperty('--font-family-mono', theme.mono);
}</code></pre>
<div class="font-info">
Current fonts: Sans (var(--font-family-sans)) | Serif (var(--font-family-serif)) |
Mono (var(--font-family-mono)) | Display (var(--font-family-display))
</div>
</div>
</div>
</section>
<!-- Available Font Presets -->
<section class="demo-section">
<h2>Available Font Presets</h2>
<div class="themes-grid">
<div
*ngFor="let preset of fontPresets"
class="theme-card"
[class.active]="preset.key === selectedPreset">
<h4>{{preset.name}}</h4>
<p class="theme-description">{{preset.description}}</p>
<div class="theme-fonts">
<div class="theme-font">
<strong>Sans:</strong>
<span>{{preset.fonts.sans}}</span>
</div>
<div class="theme-font">
<strong>Serif:</strong>
<span>{{preset.fonts.serif}}</span>
</div>
<div class="theme-font">
<strong>Mono:</strong>
<span>{{preset.fonts.mono}}</span>
</div>
<div class="theme-font">
<strong>Display:</strong>
<span>{{preset.fonts.display}}</span>
</div>
</div>
<button
class="apply-theme-btn"
(click)="applyPreset(preset.key)">
Apply {{preset.name}}
</button>
</div>
</div>
</section>
<!-- Popular Individual Fonts -->
<section class="demo-section">
<h3>Popular Individual Fonts</h3>
<p class="section-description">Load individual fonts to test and preview them.</p>
<div class="font-controls">
<div class="font-grid">
<button
*ngFor="let font of popularFonts"
class="font-button"
[class.loaded]="isLoaded(font)"
(click)="loadSingleFont(font)">
{{font}}
<span class="load-status" *ngIf="isLoaded(font)"></span>
</button>
</div>
</div>
</section>
<!-- Event Log -->
<section class="demo-section" *ngIf="eventLog.length > 0">
<h3>Event Log</h3>
<div class="event-log">
<div
*ngFor="let event of eventLog.slice(-10)"
class="event-item"
[class]="event.type">
<span class="event-time">{{event.time}}</span>
<span class="event-message">{{event.message}}</span>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,580 @@
.demo-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.demo-description {
font-size: 1.1rem;
color: #666;
margin-bottom: 2rem;
line-height: 1.6;
}
.demo-section {
margin: 3rem 0;
padding: 2rem;
border: 1px solid #e0e0e0;
border-radius: 12px;
background: #fafafa;
h2, h3 {
margin-top: 0;
color: #333;
}
h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
h3 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
}
// Loading State Display
.state-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.state-item {
padding: 1rem;
background: white;
border-radius: 8px;
border-left: 4px solid #2196F3;
strong {
color: #333;
display: block;
margin-bottom: 0.5rem;
}
.loading {
color: #FF9800;
font-weight: 500;
}
.idle {
color: #4CAF50;
font-weight: 500;
}
}
// Manual Font Controls
.font-controls {
margin-top: 1rem;
}
.font-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.font-button {
position: relative;
padding: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.9rem;
&:hover {
border-color: #2196F3;
background: #f5f5f5;
}
&.loaded {
border-color: #4CAF50;
background: #e8f5e8;
color: #2e7d32;
font-weight: 500;
}
}
.load-status {
position: absolute;
top: 0.5rem;
right: 0.5rem;
color: #4CAF50;
font-weight: bold;
}
// Transition Controls
.transition-controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.transition-btn {
padding: 1rem;
border: 2px solid #2196F3;
border-radius: 8px;
background: white;
color: #2196F3;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
&:hover {
background: #2196F3;
color: white;
}
&.scale {
border-color: #FF9800;
color: #FF9800;
&:hover {
background: #FF9800;
color: white;
}
}
&.typewriter {
border-color: #9C27B0;
color: #9C27B0;
&:hover {
background: #9C27B0;
color: white;
}
}
}
// Typography Showcase
.typography-showcase {
display: grid;
gap: 2rem;
margin-top: 1.5rem;
}
.type-sample {
padding: 2rem;
background: white;
border-radius: 12px;
border: 1px solid #e0e0e0;
transition: all 0.3s ease;
h1, h2, h3, h4 {
margin: 0 0 1rem 0;
line-height: 1.3;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
}
h2 {
font-size: 2rem;
font-weight: 600;
}
h3 {
font-size: 1.75rem;
font-weight: 500;
}
h4 {
font-size: 1.5rem;
font-weight: 500;
}
p {
line-height: 1.6;
margin-bottom: 1rem;
}
pre {
background: #f5f5f5;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1rem 0;
code {
font-size: 0.9rem;
line-height: 1.5;
}
}
}
.font-info {
font-size: 0.8rem;
color: #666;
font-style: italic;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
}
// Available Themes
.themes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.theme-card {
padding: 1.5rem;
background: white;
border: 1px solid #e0e0e0;
border-radius: 12px;
transition: all 0.3s ease;
&:hover {
border-color: #2196F3;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.1);
}
h4 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.1rem;
}
}
.theme-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
line-height: 1.4;
}
.theme-fonts {
margin-bottom: 1.5rem;
}
.theme-font {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.85rem;
strong {
color: #333;
min-width: 60px;
}
& + .theme-font {
border-top: 1px solid #f0f0f0;
}
}
.apply-theme-btn {
width: 100%;
padding: 0.75rem;
border: 1px solid #2196F3;
border-radius: 6px;
background: #2196F3;
color: white;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
&:hover {
background: #1976D2;
border-color: #1976D2;
}
}
// Event Log
.event-log {
max-height: 300px;
overflow-y: auto;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1rem;
}
.event-item {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
font-size: 0.9rem;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.success {
color: #4CAF50;
}
&.error {
color: #f44336;
}
&.info {
color: #2196F3;
}
}
.event-time {
color: #666;
font-size: 0.8rem;
min-width: 80px;
font-family: var(--font-family-mono, 'Courier New', monospace);
}
.event-message {
flex: 1;
}
// Font Preset Controls
.preset-controls {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1rem;
}
.preset-selector {
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
select {
width: 100%;
padding: 0.75rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
background: white;
transition: border-color 0.3s ease;
&:focus {
outline: none;
border-color: #2196F3;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.transition-selector {
display: flex;
gap: 1rem;
align-items: end;
flex-wrap: wrap;
label {
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
display: block;
}
select {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.9rem;
min-width: 120px;
}
}
.apply-btn {
padding: 0.75rem 1.5rem;
background: #2196F3;
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
&:hover:not(:disabled) {
background: #1976D2;
transform: translateY(-2px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
}
// Current Preset Display
.current-preset {
margin-top: 2rem;
padding: 1.5rem;
background: #e8f5e8;
border: 1px solid #4CAF50;
border-radius: 12px;
h4 {
margin: 0 0 1rem 0;
color: #2e7d32;
font-size: 1.1rem;
}
}
.preset-fonts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem;
}
.font-assignment {
display: flex;
justify-content: space-between;
padding: 0.5rem;
background: white;
border-radius: 6px;
font-size: 0.9rem;
.font-type {
font-weight: 600;
color: #333;
}
.font-name {
color: #666;
font-style: italic;
}
}
// Theme card active state
.theme-card.active {
border-color: #4CAF50;
background: #f1f8e9;
h4 {
color: #2e7d32;
}
}
// Section descriptions
.preset-description,
.preview-description,
.section-description {
color: #666;
font-size: 1rem;
margin-bottom: 1rem;
line-height: 1.5;
}
// Font transition animations
:global(html.font-transition-fade) {
* {
transition: font-family 0.8s ease-in-out;
}
}
:global(html.font-transition-scale) {
* {
transition: font-family 0.6s ease-in-out, transform 0.6s ease-in-out;
}
body {
transform: scale(1.02);
transition: transform 0.3s ease-in-out;
}
&.font-transition-scale body {
transform: scale(1);
}
}
:global(html.font-transition-typewriter) {
* {
transition: font-family 0.5s steps(10, end);
}
}
// Enhanced blockquote styling
blockquote {
margin: 1.5rem 0;
padding: 1rem 1.5rem;
border-left: 4px solid #2196F3;
background: #f8f9fa;
font-style: italic;
position: relative;
&::before {
content: '"';
font-size: 3rem;
color: #2196F3;
position: absolute;
top: -0.5rem;
left: 1rem;
opacity: 0.3;
}
}
// Responsive Design
@media (max-width: 768px) {
.demo-container {
padding: 1rem;
}
.demo-section {
padding: 1.5rem;
}
.font-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.transition-controls {
grid-template-columns: 1fr;
}
.themes-grid {
grid-template-columns: 1fr;
}
.state-info {
grid-template-columns: 1fr;
}
.type-sample {
padding: 1.5rem;
h1 { font-size: 2rem; }
h2 { font-size: 1.75rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
}
.preset-controls {
gap: 1rem;
}
.transition-selector {
flex-direction: column;
align-items: stretch;
gap: 1rem;
select,
.apply-btn {
width: 100%;
}
}
.preset-fonts {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,448 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
// Mock font combinations since ui-font-manager is not yet available
const FONT_COMBINATIONS = {
'Modern Clean': {
sans: 'Inter',
serif: 'Playfair Display',
mono: 'JetBrains Mono',
display: 'Inter'
},
'Classic Editorial': {
sans: 'Source Sans Pro',
serif: 'Lora',
mono: 'Source Code Pro',
display: 'Source Sans Pro'
},
'Friendly Modern': {
sans: 'Poppins',
serif: 'Merriweather',
mono: 'Fira Code',
display: 'Poppins'
},
'Professional': {
sans: 'Roboto',
serif: 'EB Garamond',
mono: 'Roboto Mono',
display: 'Roboto'
}
};
@Component({
selector: 'app-font-manager-demo',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="demo-container">
<h1>UI Font Manager Demo</h1>
<p class="demo-description">
Dynamically load Google Fonts and switch between font themes with smooth transitions.
Select a font preset to apply it globally to the entire application.
</p>
<!-- Font Preset Selection -->
<section class="demo-section">
<h2>Font Preset Selection</h2>
<p class="preset-description">
Choose from curated font combinations. The selected preset will be applied to the entire application.
</p>
<div class="preset-controls">
<div class="preset-selector">
<label for="preset-select">Choose Font Preset:</label>
<select
id="preset-select"
[value]="selectedPreset"
(change)="onPresetChange($event)">
<option value="">Select a preset...</option>
<option *ngFor="let preset of fontPresets" [value]="preset.key">
{{preset.name}} - {{preset.description}}
</option>
</select>
</div>
<div class="transition-selector" *ngIf="selectedPreset">
<label for="transition-select">Transition Effect:</label>
<select id="transition-select" [(ngModel)]="selectedTransition">
<option value="fade">Fade</option>
<option value="scale">Scale</option>
<option value="typewriter">Typewriter</option>
</select>
<button
class="apply-btn"
(click)="applyPresetWithTransition()">
Apply with {{selectedTransition | titlecase}} Effect
</button>
</div>
</div>
<!-- Current Preset Display -->
<div class="current-preset" *ngIf="currentPreset">
<h4>Currently Applied: {{currentPreset.name}}</h4>
<div class="preset-fonts">
<div class="font-assignment">
<span class="font-type">Sans-serif:</span>
<span class="font-name">{{currentPreset.fonts.sans}}</span>
</div>
<div class="font-assignment">
<span class="font-type">Serif:</span>
<span class="font-name">{{currentPreset.fonts.serif}}</span>
</div>
<div class="font-assignment">
<span class="font-type">Monospace:</span>
<span class="font-name">{{currentPreset.fonts.mono}}</span>
</div>
<div class="font-assignment">
<span class="font-type">Display:</span>
<span class="font-name">{{currentPreset.fonts.display}}</span>
</div>
</div>
</div>
</section>
<!-- Typography Preview -->
<section class="demo-section">
<h2>Typography Preview</h2>
<p class="preview-description">See how the selected fonts look in a real application context.</p>
<div class="typography-showcase">
<div class="type-sample">
<h1 style="font-family: var(--font-family-display)">Display Heading (H1)</h1>
<h2 style="font-family: var(--font-family-sans)">Sans-serif Heading (H2)</h2>
<h3 style="font-family: var(--font-family-serif)">Serif Heading (H3)</h3>
<p style="font-family: var(--font-family-sans)">
This is a paragraph using the sans-serif font. It demonstrates how readable body text
appears with the currently selected font combination. Lorem ipsum dolor sit amet,
consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
<blockquote style="font-family: var(--font-family-serif)">
"This is a quote using the serif font, which often provides better readability
for longer text passages and adds elegance to quoted content."
</blockquote>
<div class="font-info">
Current fonts: Sans (var(--font-family-sans)) | Serif (var(--font-family-serif)) |
Mono (var(--font-family-mono)) | Display (var(--font-family-display))
</div>
</div>
</div>
</section>
<!-- Available Font Presets -->
<section class="demo-section">
<h2>Available Font Presets</h2>
<div class="themes-grid">
<div
*ngFor="let preset of fontPresets"
class="theme-card"
[class.active]="preset.key === selectedPreset">
<h4>{{preset.name}}</h4>
<p class="theme-description">{{preset.description}}</p>
<div class="theme-fonts">
<div class="theme-font">
<strong>Sans:</strong>
<span>{{preset.fonts.sans}}</span>
</div>
<div class="theme-font">
<strong>Serif:</strong>
<span>{{preset.fonts.serif}}</span>
</div>
<div class="theme-font">
<strong>Mono:</strong>
<span>{{preset.fonts.mono}}</span>
</div>
<div class="theme-font">
<strong>Display:</strong>
<span>{{preset.fonts.display}}</span>
</div>
</div>
<button
class="apply-theme-btn"
(click)="applyPreset(preset.key)">
Apply {{preset.name}}
</button>
</div>
</div>
</section>
<!-- Popular Individual Fonts -->
<section class="demo-section">
<h3>Popular Individual Fonts</h3>
<p class="section-description">Load individual fonts to test and preview them.</p>
<div class="font-controls">
<div class="font-grid">
<button
*ngFor="let font of popularFonts"
class="font-button"
[class.loaded]="isLoaded(font)"
(click)="loadSingleFont(font)">
{{font}}
<span class="load-status" *ngIf="isLoaded(font)">✓</span>
</button>
</div>
</div>
</section>
<!-- Event Log -->
<section class="demo-section" *ngIf="eventLog.length > 0">
<h3>Event Log</h3>
<div class="event-log">
<div
*ngFor="let event of eventLog.slice(-10)"
class="event-item"
[class]="event.type">
<span class="event-time">{{event.time}}</span>
<span class="event-message">{{event.message}}</span>
</div>
</div>
</section>
</div>
`,
styleUrl: './font-manager-demo.component.scss'
})
export class FontManagerDemoComponent implements OnInit, OnDestroy {
popularFonts = ['Inter', 'Roboto', 'Playfair Display', 'JetBrains Mono', 'Montserrat', 'Lora'];
loadedFonts = new Set<string>();
// Font preset management
fontPresets = [
{
key: 'modern-clean',
name: 'Modern Clean',
description: 'Contemporary and minimal design with Inter and Playfair Display',
fonts: FONT_COMBINATIONS['Modern Clean']
},
{
key: 'classic-editorial',
name: 'Classic Editorial',
description: 'Traditional publishing style with Source Sans Pro and Lora',
fonts: FONT_COMBINATIONS['Classic Editorial']
},
{
key: 'friendly-modern',
name: 'Friendly Modern',
description: 'Approachable and warm with Poppins and Merriweather',
fonts: FONT_COMBINATIONS['Friendly Modern']
},
{
key: 'professional',
name: 'Professional',
description: 'Corporate and reliable with Roboto and EB Garamond',
fonts: FONT_COMBINATIONS['Professional']
},
{
key: 'system',
name: 'System Default',
description: 'Use system fonts for optimal performance',
fonts: {
sans: 'system-ui',
serif: 'ui-serif',
mono: 'ui-monospace',
display: 'system-ui'
}
}
];
selectedPreset: string = '';
selectedTransition: 'fade' | 'scale' | 'typewriter' = 'fade';
currentPreset: any = null;
eventLog: Array<{time: string, message: string, type: 'success' | 'error' | 'info'}> = [];
private destroy$ = new Subject<void>();
constructor() {}
ngOnInit(): void {
this.logEvent('Component initialized', 'info');
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onThemeChanged(themeName: string): void {
this.logEvent(`Theme changed to: ${themeName}`, 'success');
}
onFontLoaded(fontName: string): void {
this.logEvent(`Font loaded: ${fontName}`, 'success');
this.loadedFonts.add(fontName);
}
onFontError(event: {fontName: string, error: string}): void {
this.logEvent(`Font error - ${event.fontName}: ${event.error}`, 'error');
}
loadSingleFont(fontName: string): void {
this.logEvent(`Loading font: ${fontName}`, 'info');
// Load font via Google Fonts API
this.loadGoogleFont(fontName);
this.loadedFonts.add(fontName);
this.logEvent(`Successfully loaded: ${fontName}`, 'success');
}
applyTheme(themeName: string): void {
this.logEvent(`Applying theme: ${themeName}`, 'info');
this.logEvent(`Successfully applied theme: ${themeName}`, 'success');
}
isLoaded(fontName: string): boolean {
return this.loadedFonts.has(fontName);
}
private loadGoogleFont(fontName: string): void {
const link = document.createElement('link');
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(' ', '+')}&display=swap`;
link.rel = 'stylesheet';
document.head.appendChild(link);
}
onPresetChange(event: Event): void {
const target = event.target as HTMLSelectElement;
this.selectedPreset = target.value;
if (this.selectedPreset) {
const preset = this.fontPresets.find(p => p.key === this.selectedPreset);
if (preset) {
this.logEvent(`Selected preset: ${preset.name}`, 'info');
}
}
}
applyPreset(presetKey: string): void {
const preset = this.fontPresets.find(p => p.key === presetKey);
if (!preset) {
this.logEvent(`Preset not found: ${presetKey}`, 'error');
return;
}
this.selectedPreset = presetKey;
this.logEvent(`Applying preset: ${preset.name}`, 'info');
// Apply fonts to document root for global effect
this.applyFontsGlobally(preset.fonts);
this.currentPreset = preset;
}
applyPresetWithTransition(): void {
const preset = this.fontPresets.find(p => p.key === this.selectedPreset);
if (!preset) return;
this.logEvent(`Applying preset "${preset.name}" with ${this.selectedTransition} transition`, 'info');
// Apply fonts with transition effect
const fontVariables = {
'--font-family-sans': this.buildGlobalFontStack(preset.fonts.sans),
'--font-family-serif': this.buildGlobalFontStack(preset.fonts.serif),
'--font-family-mono': this.buildGlobalFontStack(preset.fonts.mono),
'--font-family-display': this.buildGlobalFontStack(preset.fonts.display)
};
this.applyFontsWithTransition(fontVariables, this.selectedTransition);
this.currentPreset = preset;
}
private applyFontsGlobally(fonts: any): void {
const root = document.documentElement;
// Set CSS custom properties on the document root
root.style.setProperty('--font-family-sans', this.buildGlobalFontStack(fonts.sans));
root.style.setProperty('--font-family-serif', this.buildGlobalFontStack(fonts.serif));
root.style.setProperty('--font-family-mono', this.buildGlobalFontStack(fonts.mono));
root.style.setProperty('--font-family-display', this.buildGlobalFontStack(fonts.display));
// Also load the fonts if they're Google Fonts
if (fonts.sans && fonts.sans !== 'system-ui') {
this.loadSingleFont(fonts.sans);
}
if (fonts.serif && fonts.serif !== 'ui-serif') {
this.loadSingleFont(fonts.serif);
}
if (fonts.mono && fonts.mono !== 'ui-monospace') {
this.loadSingleFont(fonts.mono);
}
if (fonts.display && fonts.display !== 'system-ui') {
this.loadSingleFont(fonts.display);
}
this.logEvent(`Applied fonts globally to document root`, 'success');
}
private applyFontsWithTransition(fontVariables: Record<string, string>, transitionType: 'fade' | 'scale' | 'typewriter'): void {
const root = document.documentElement;
// Add transition class
root.classList.add(`font-transition-${transitionType}`);
// Apply font changes
Object.entries(fontVariables).forEach(([property, value]) => {
root.style.setProperty(property, value);
});
// Remove transition class after animation
setTimeout(() => {
root.classList.remove(`font-transition-${transitionType}`);
this.logEvent(`${transitionType} transition completed`, 'success');
}, 1000);
}
private buildGlobalFontStack(fontName: string): string {
// Handle system fonts
if (fontName === 'system-ui') {
return 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
}
if (fontName === 'ui-serif') {
return 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif';
}
if (fontName === 'ui-monospace') {
return 'ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
}
// For Google Fonts, add appropriate fallbacks
const fontMap: Record<string, string> = {
'Inter': `"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`,
'Roboto': `"Roboto", Arial, Helvetica, sans-serif`,
'Poppins': `"Poppins", Arial, Helvetica, sans-serif`,
'Source Sans Pro': `"Source Sans Pro", Arial, Helvetica, sans-serif`,
'Montserrat': `"Montserrat", Arial, Helvetica, sans-serif`,
'Playfair Display': `"Playfair Display", Georgia, "Times New Roman", serif`,
'Lora': `"Lora", Georgia, "Times New Roman", serif`,
'Merriweather': `"Merriweather", Georgia, "Times New Roman", serif`,
'EB Garamond': `"EB Garamond", Georgia, "Times New Roman", serif`,
'JetBrains Mono': `"JetBrains Mono", Consolas, Monaco, "Courier New", monospace`,
'Source Code Pro': `"Source Code Pro", Consolas, Monaco, "Courier New", monospace`,
'Fira Code': `"Fira Code", Consolas, Monaco, "Courier New", monospace`,
'Roboto Mono': `"Roboto Mono", Consolas, Monaco, "Courier New", monospace`,
'Oswald': `"Oswald", Impact, "Arial Black", sans-serif`,
'Raleway': `"Raleway", Arial, Helvetica, sans-serif`
};
return fontMap[fontName] || `"${fontName}", sans-serif`;
}
private logEvent(message: string, type: 'success' | 'error' | 'info'): void {
const time = new Date().toLocaleTimeString();
this.eventLog.push({ time, message, type });
// Keep only last 50 events
if (this.eventLog.length > 50) {
this.eventLog = this.eventLog.slice(-50);
}
}
}

View File

@@ -0,0 +1 @@
export * from './font-manager-demo.component';

View File

@@ -0,0 +1,274 @@
@use '../../../../../ui-design-system/src/styles/semantic' as *;
.demo-container {
padding: $semantic-spacing-component-lg;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
margin-bottom: $semantic-spacing-layout-section-xl;
h3 {
font-family: map-get($semantic-typography-heading-h3, font-family);
font-size: map-get($semantic-typography-heading-h3, font-size);
font-weight: map-get($semantic-typography-heading-h3, font-weight);
line-height: map-get($semantic-typography-heading-h3, line-height);
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-md;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
padding-bottom: $semantic-spacing-component-sm;
}
h4 {
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-component-sm;
}
p {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-component-md;
}
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: $semantic-spacing-grid-gap-lg;
margin-bottom: $semantic-spacing-layout-section-md;
}
.demo-item {
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-secondary;
border-radius: $semantic-border-radius-md;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
}
.demo-controls {
display: flex;
flex-wrap: wrap;
gap: $semantic-spacing-component-sm;
margin-bottom: $semantic-spacing-component-lg;
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-elevated;
border-radius: $semantic-border-radius-md;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
button {
padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-sm;
background: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
cursor: pointer;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
font-family: map-get($semantic-typography-button-medium, font-family);
font-size: map-get($semantic-typography-button-medium, font-size);
font-weight: map-get($semantic-typography-button-medium, font-weight);
line-height: map-get($semantic-typography-button-medium, line-height);
&:hover {
background: $semantic-color-surface-elevated;
box-shadow: $semantic-shadow-button-hover;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&.active {
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border-color: $semantic-color-primary;
}
&:disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
}
}
.selection-info {
margin-top: $semantic-spacing-component-lg;
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-elevated;
border-radius: $semantic-border-radius-md;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
p {
font-weight: $semantic-typography-font-weight-semibold;
margin-bottom: $semantic-spacing-component-sm;
}
ul {
list-style: none;
padding: 0;
margin: 0;
li {
padding: $semantic-spacing-content-line-tight 0;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
&:last-child {
border-bottom: none;
}
}
}
}
.event-log {
background: $semantic-color-surface-elevated;
border-radius: $semantic-border-radius-md;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
overflow: hidden;
.event-controls {
padding: $semantic-spacing-component-md;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
background: $semantic-color-surface-secondary;
button {
padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-sm;
background: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
cursor: pointer;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
font-family: map-get($semantic-typography-button-small, font-family);
font-size: map-get($semantic-typography-button-small, font-size);
font-weight: map-get($semantic-typography-button-small, font-weight);
line-height: map-get($semantic-typography-button-small, line-height);
&:hover {
background: $semantic-color-surface-elevated;
box-shadow: $semantic-shadow-button-hover;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
}
}
.event-list {
max-height: 300px;
overflow-y: auto;
.no-events {
padding: $semantic-spacing-component-lg;
text-align: center;
color: $semantic-color-text-tertiary;
font-style: italic;
}
.event-item {
display: grid;
grid-template-columns: 120px 1fr auto;
gap: $semantic-spacing-component-sm;
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
&:last-child {
border-bottom: none;
}
.event-type {
font-weight: $semantic-typography-font-weight-semibold;
color: $semantic-color-primary;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
line-height: map-get($semantic-typography-body-small, line-height);
}
.event-details {
color: $semantic-color-text-primary;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
}
.event-time {
color: $semantic-color-text-tertiary;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
white-space: nowrap;
}
}
}
}
// Responsive Design
@media (max-width: $semantic-breakpoint-md - 1) {
.demo-container {
padding: $semantic-spacing-component-md;
}
.demo-grid {
grid-template-columns: 1fr;
gap: $semantic-spacing-grid-gap-md;
}
.demo-controls {
flex-direction: column;
align-items: stretch;
button {
width: 100%;
text-align: center;
}
}
.event-item {
grid-template-columns: 1fr;
gap: $semantic-spacing-content-line-tight;
.event-time {
justify-self: end;
grid-row: 1;
grid-column: 1;
}
.event-type {
grid-row: 2;
grid-column: 1;
}
.event-details {
grid-row: 3;
grid-column: 1;
}
}
}
@media (max-width: $semantic-breakpoint-sm - 1) {
.demo-container {
padding: $semantic-spacing-component-sm;
}
.demo-section {
margin-bottom: $semantic-spacing-layout-section-md;
}
}

View File

@@ -0,0 +1,430 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GalleryGridComponent, GalleryGridItem } from 'ui-essentials';
@Component({
selector: 'ui-gallery-grid-demo',
standalone: true,
imports: [CommonModule, GalleryGridComponent],
template: `
<div class="demo-container">
<h2>Gallery Grid Demo</h2>
<p>A responsive media grid with lightbox functionality for organizing and displaying images.</p>
<!-- Column Variants -->
<section class="demo-section">
<h3>Column Variants</h3>
<div class="demo-grid">
@for (columnCount of columnVariants; track columnCount) {
<div class="demo-item">
<h4>{{ columnCount }} Columns</h4>
<ui-gallery-grid
[items]="sampleItems.slice(0, 6)"
[columns]="columnCount">
</ui-gallery-grid>
</div>
}
</div>
</section>
<!-- Gap Size Variants -->
<section class="demo-section">
<h3>Gap Sizes</h3>
<div class="demo-grid">
@for (gap of gapSizes; track gap) {
<div class="demo-item">
<h4>{{ gap | titlecase }} Gap</h4>
<ui-gallery-grid
[items]="sampleItems.slice(0, 6)"
[columns]="3"
[gap]="gap">
</ui-gallery-grid>
</div>
}
</div>
</section>
<!-- Object Fit Variants -->
<section class="demo-section">
<h3>Object Fit Options</h3>
<div class="demo-grid">
@for (fit of objectFitOptions; track fit) {
<div class="demo-item">
<h4>{{ fit | titlecase }} Fit</h4>
<ui-gallery-grid
[items]="landscapeItems"
[columns]="3"
[objectFit]="fit">
</ui-gallery-grid>
</div>
}
</div>
</section>
<!-- Auto-fit Responsive -->
<section class="demo-section">
<h3>Auto-fit Responsive</h3>
<p>Automatically adjusts columns based on available space (resize window to see effect):</p>
<ui-gallery-grid
[items]="sampleItems"
columns="auto-fit"
[gap]="'md'">
</ui-gallery-grid>
</section>
<!-- Masonry Layout -->
<section class="demo-section">
<h3>Masonry Layout</h3>
<p>Items align to top with varying heights:</p>
<ui-gallery-grid
[items]="masonryItems"
[columns]="4"
[masonry]="true"
[gap]="'sm'">
</ui-gallery-grid>
</section>
<!-- Interactive Features -->
<section class="demo-section">
<h3>Interactive Features</h3>
<div class="demo-controls">
<button (click)="toggleOverlay()" [class.active]="showOverlay">
{{ showOverlay ? 'Hide' : 'Show' }} Overlay
</button>
<button (click)="toggleZoomIndicator()" [class.active]="showZoomIndicator">
{{ showZoomIndicator ? 'Hide' : 'Show' }} Zoom Indicator
</button>
<button (click)="toggleSelection()" [class.active]="selectionEnabled">
{{ selectionEnabled ? 'Disable' : 'Enable' }} Selection
</button>
<button (click)="selectAll()" [disabled]="!selectionEnabled">
Select All
</button>
<button (click)="deselectAll()" [disabled]="!selectionEnabled">
Deselect All
</button>
</div>
<ui-gallery-grid
[items]="interactiveItems"
[columns]="4"
[showOverlay]="showOverlay"
[showZoomIndicator]="showZoomIndicator"
(itemClick)="handleItemClick($event)"
(itemSelect)="handleItemSelect($event)"
(imageLoad)="handleImageLoad($event)"
(imageError)="handleImageError($event)">
</ui-gallery-grid>
@if (selectedItems.length > 0) {
<div class="selection-info">
<p>Selected Items: {{ selectedItems.length }}</p>
<ul>
@for (item of selectedItems; track item.id) {
<li>{{ item.title || item.alt || 'Item ' + item.id }}</li>
}
</ul>
</div>
}
</section>
<!-- Empty State -->
<section class="demo-section">
<h3>Empty State</h3>
<ui-gallery-grid
[items]="[]"
[columns]="3"
emptyText="No photos in this album yet">
</ui-gallery-grid>
</section>
<!-- Loading State -->
<section class="demo-section">
<h3>Loading State</h3>
<ui-gallery-grid
[items]="loadingItems"
[columns]="3">
</ui-gallery-grid>
</section>
<!-- Event Log -->
<section class="demo-section">
<h3>Event Log</h3>
<div class="event-log">
<div class="event-controls">
<button (click)="clearEventLog()">Clear Log</button>
</div>
<div class="event-list">
@if (eventLog.length === 0) {
<p class="no-events">No events yet. Interact with the gallery above to see events.</p>
} @else {
@for (event of eventLog; track event.id) {
<div class="event-item">
<span class="event-type">{{ event.type }}</span>
<span class="event-details">{{ event.details }}</span>
<span class="event-time">{{ event.timestamp | date:'HH:mm:ss' }}</span>
</div>
}
}
</div>
</div>
</section>
</div>
`,
styleUrl: './gallery-grid-demo.component.scss'
})
export class GalleryGridDemoComponent {
columnVariants = [2, 3, 4] as const;
gapSizes = ['sm', 'md', 'lg'] as const;
objectFitOptions = ['cover', 'contain', 'fill'] as const;
showOverlay = true;
showZoomIndicator = true;
selectionEnabled = false;
selectedItems: GalleryGridItem[] = [];
eventLog: Array<{id: number; type: string; details: string; timestamp: Date}> = [];
sampleItems: GalleryGridItem[] = [
{
id: 1,
src: 'https://picsum.photos/400/300?random=1',
thumbnail: 'https://picsum.photos/200/150?random=1',
alt: 'Sample image 1',
title: 'Mountain Landscape',
caption: 'Beautiful mountain vista at sunset'
},
{
id: 2,
src: 'https://picsum.photos/400/300?random=2',
thumbnail: 'https://picsum.photos/200/150?random=2',
alt: 'Sample image 2',
title: 'Ocean Waves',
caption: 'Crashing waves on a sandy beach'
},
{
id: 3,
src: 'https://picsum.photos/400/300?random=3',
thumbnail: 'https://picsum.photos/200/150?random=3',
alt: 'Sample image 3',
title: 'Forest Path',
caption: 'Winding trail through dense woods'
},
{
id: 4,
src: 'https://picsum.photos/400/300?random=4',
thumbnail: 'https://picsum.photos/200/150?random=4',
alt: 'Sample image 4',
title: 'City Skyline',
caption: 'Urban architecture at night'
},
{
id: 5,
src: 'https://picsum.photos/400/300?random=5',
thumbnail: 'https://picsum.photos/200/150?random=5',
alt: 'Sample image 5',
title: 'Desert Dunes',
caption: 'Rolling sand dunes in morning light'
},
{
id: 6,
src: 'https://picsum.photos/400/300?random=6',
thumbnail: 'https://picsum.photos/200/150?random=6',
alt: 'Sample image 6',
title: 'Lake Reflection',
caption: 'Mirror-like water surface'
},
{
id: 7,
src: 'https://picsum.photos/400/300?random=7',
thumbnail: 'https://picsum.photos/200/150?random=7',
alt: 'Sample image 7',
title: 'Flower Field',
caption: 'Colorful wildflowers in bloom'
},
{
id: 8,
src: 'https://picsum.photos/400/300?random=8',
thumbnail: 'https://picsum.photos/200/150?random=8',
alt: 'Sample image 8',
title: 'Snow Peaks',
caption: 'Snow-capped mountain range'
}
];
landscapeItems: GalleryGridItem[] = [
{
id: 'l1',
src: 'https://picsum.photos/800/400?random=11',
thumbnail: 'https://picsum.photos/200/100?random=11',
alt: 'Landscape image 1',
title: 'Wide Landscape',
caption: 'Panoramic view'
},
{
id: 'l2',
src: 'https://picsum.photos/800/400?random=12',
thumbnail: 'https://picsum.photos/200/100?random=12',
alt: 'Landscape image 2',
title: 'River Valley',
caption: 'Flowing water through hills'
},
{
id: 'l3',
src: 'https://picsum.photos/800/400?random=13',
thumbnail: 'https://picsum.photos/200/100?random=13',
alt: 'Landscape image 3',
title: 'Coastal View',
caption: 'Rocky coastline'
}
];
masonryItems: GalleryGridItem[] = [
{
id: 'm1',
src: 'https://picsum.photos/300/400?random=21',
thumbnail: 'https://picsum.photos/150/200?random=21',
alt: 'Tall image 1',
title: 'Portrait 1'
},
{
id: 'm2',
src: 'https://picsum.photos/300/200?random=22',
thumbnail: 'https://picsum.photos/150/100?random=22',
alt: 'Wide image 1',
title: 'Landscape 1'
},
{
id: 'm3',
src: 'https://picsum.photos/300/500?random=23',
thumbnail: 'https://picsum.photos/150/250?random=23',
alt: 'Very tall image',
title: 'Portrait 2'
},
{
id: 'm4',
src: 'https://picsum.photos/300/300?random=24',
thumbnail: 'https://picsum.photos/150/150?random=24',
alt: 'Square image',
title: 'Square 1'
},
{
id: 'm5',
src: 'https://picsum.photos/300/350?random=25',
thumbnail: 'https://picsum.photos/150/175?random=25',
alt: 'Tall image 2',
title: 'Portrait 3'
},
{
id: 'm6',
src: 'https://picsum.photos/300/250?random=26',
thumbnail: 'https://picsum.photos/150/125?random=26',
alt: 'Medium image',
title: 'Landscape 2'
}
];
interactiveItems: GalleryGridItem[] = this.sampleItems.slice(0, 8).map(item => ({
...item,
selected: false
}));
loadingItems: GalleryGridItem[] = [
{
id: 'loading1',
src: 'https://picsum.photos/400/300?random=31',
thumbnail: 'https://picsum.photos/200/150?random=31',
alt: 'Loading image 1',
title: 'Loading Item 1',
loading: true
},
{
id: 'loading2',
src: 'https://picsum.photos/400/300?random=32',
thumbnail: 'https://picsum.photos/200/150?random=32',
alt: 'Loading image 2',
title: 'Loading Item 2',
loading: true
},
{
id: 'loading3',
src: 'https://picsum.photos/400/300?random=33',
thumbnail: 'https://picsum.photos/200/150?random=33',
alt: 'Loading image 3',
title: 'Loaded Item',
loading: false
}
];
private eventCounter = 0;
toggleOverlay(): void {
this.showOverlay = !this.showOverlay;
}
toggleZoomIndicator(): void {
this.showZoomIndicator = !this.showZoomIndicator;
}
toggleSelection(): void {
this.selectionEnabled = !this.selectionEnabled;
if (!this.selectionEnabled) {
this.deselectAll();
}
}
selectAll(): void {
this.interactiveItems.forEach(item => item.selected = true);
this.updateSelectedItems();
}
deselectAll(): void {
this.interactiveItems.forEach(item => item.selected = false);
this.updateSelectedItems();
}
handleItemClick(event: { item: GalleryGridItem; event: MouseEvent }): void {
this.addEvent('Item Click', `Clicked item: ${event.item.title || event.item.id}`);
if (this.selectionEnabled) {
event.item.selected = !event.item.selected;
this.updateSelectedItems();
this.addEvent('Item Selection', `${event.item.selected ? 'Selected' : 'Deselected'} item: ${event.item.title || event.item.id}`);
}
}
handleItemSelect(item: GalleryGridItem): void {
this.updateSelectedItems();
this.addEvent('Item Select', `Selected item: ${item.title || item.id}`);
}
handleImageLoad(event: { item: GalleryGridItem; event: Event }): void {
this.addEvent('Image Load', `Loaded image: ${event.item.title || event.item.id}`);
}
handleImageError(event: { item: GalleryGridItem; event: Event }): void {
this.addEvent('Image Error', `Failed to load image: ${event.item.title || event.item.id}`);
}
clearEventLog(): void {
this.eventLog = [];
}
private updateSelectedItems(): void {
this.selectedItems = this.interactiveItems.filter(item => item.selected);
}
private addEvent(type: string, details: string): void {
this.eventLog.unshift({
id: ++this.eventCounter,
type,
details,
timestamp: new Date()
});
// Keep only the last 20 events
if (this.eventLog.length > 20) {
this.eventLog = this.eventLog.slice(0, 20);
}
}
}

View File

@@ -0,0 +1 @@
<p>hcl-studio-demo works!</p>

View File

@@ -0,0 +1,478 @@
/**
* HCL Studio Demo Styles
* Showcases dynamic theming using CSS custom properties
*/
.hcl-studio-demo {
padding: 2rem;
background: var(--color-surface);
color: var(--color-on-surface);
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
// ==========================================================================
// HEADER STYLES
// ==========================================================================
.demo-header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: var(--color-surface-container);
border-radius: 1rem;
h1 {
margin: 0 0 0.5rem 0;
color: var(--color-primary);
font-size: 2.5rem;
font-weight: 600;
}
p {
margin: 0;
color: var(--color-on-surface-variant);
font-size: 1.2rem;
}
}
// ==========================================================================
// CURRENT THEME INFO
// ==========================================================================
.current-theme-info {
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--color-surface-high);
border-radius: 0.5rem;
border: 1px solid var(--color-outline-variant);
h3 {
margin: 0 0 1rem 0;
color: var(--color-on-surface);
}
.color-swatches {
display: flex;
gap: 1rem;
.swatch {
flex: 1;
padding: 1rem;
border-radius: 0.5rem;
text-align: center;
color: white;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
span {
font-size: 0.9rem;
}
}
}
}
// ==========================================================================
// MODE CONTROLS
// ==========================================================================
.mode-controls {
margin-bottom: 2rem;
text-align: center;
.mode-toggle-btn {
padding: 0.75rem 2rem;
border: 2px solid var(--color-outline);
border-radius: 2rem;
background: var(--color-surface);
color: var(--color-on-surface);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: var(--color-surface-high);
border-color: var(--color-primary);
}
&.dark {
background: var(--color-primary);
color: var(--color-on-primary);
border-color: var(--color-primary);
}
}
}
// ==========================================================================
// THEMES SECTION
// ==========================================================================
.themes-section {
margin-bottom: 3rem;
h3 {
margin: 0 0 1.5rem 0;
color: var(--color-on-surface);
font-size: 1.5rem;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
.theme-card {
padding: 1rem;
background: var(--color-surface-container);
border: 2px solid var(--color-outline-variant);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.active {
border-color: var(--color-primary);
background: var(--color-primary-container);
.theme-name {
color: var(--color-on-primary-container);
font-weight: 600;
}
}
.theme-preview {
display: flex;
height: 40px;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
.color-strip {
flex: 1;
}
}
.theme-name {
display: block;
text-align: center;
color: var(--color-on-surface);
font-size: 0.9rem;
font-weight: 500;
}
}
}
}
// ==========================================================================
// CUSTOM THEME SECTION
// ==========================================================================
.custom-theme-section {
margin-bottom: 3rem;
padding: 2rem;
background: var(--color-surface-high);
border-radius: 1rem;
border: 1px solid var(--color-outline-variant);
h3 {
margin: 0 0 1.5rem 0;
color: var(--color-on-surface);
font-size: 1.5rem;
}
.custom-inputs {
display: grid;
gap: 1.5rem;
margin-bottom: 2rem;
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
}
.color-input-group {
label {
display: block;
margin-bottom: 0.5rem;
color: var(--color-on-surface);
font-weight: 600;
font-size: 0.9rem;
}
input[type="color"] {
width: 50px;
height: 40px;
border: 2px solid var(--color-outline);
border-radius: 0.5rem;
margin-right: 0.5rem;
cursor: pointer;
}
input[type="text"] {
flex: 1;
padding: 0.75rem;
border: 2px solid var(--color-outline);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-on-surface);
font-size: 1rem;
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
}
}
.custom-actions {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
&.preview-btn {
background: var(--color-secondary);
color: var(--color-on-secondary);
border: none;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
&.apply-btn {
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
}
}
.color-preview {
h4 {
margin: 0 0 1rem 0;
color: var(--color-on-surface);
}
.preview-swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.5rem;
.preview-swatch {
padding: 0.75rem 0.5rem;
border-radius: 0.5rem;
text-align: center;
font-size: 0.8rem;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
}
}
}
// ==========================================================================
// DEMO COMPONENTS
// ==========================================================================
.demo-components {
h3 {
margin: 0 0 1.5rem 0;
color: var(--color-on-surface);
font-size: 1.5rem;
}
.component-showcase {
display: grid;
gap: 2rem;
@media (min-width: 768px) {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.showcase-section {
padding: 1.5rem;
background: var(--color-surface-container);
border-radius: 0.75rem;
border: 1px solid var(--color-outline-variant);
h4 {
margin: 0 0 1rem 0;
color: var(--color-on-surface);
font-size: 1.2rem;
}
// Button Styles
.button-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.3s ease;
&.btn-primary {
background: var(--color-primary);
color: var(--color-on-primary);
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
&.btn-secondary {
background: var(--color-secondary);
color: var(--color-on-secondary);
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
&.btn-tertiary {
background: var(--color-tertiary);
color: var(--color-on-tertiary);
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
}
}
// Card Styles
.card-group {
display: flex;
flex-direction: column;
gap: 1rem;
.demo-card {
padding: 1.5rem;
border-radius: 0.5rem;
&.primary {
background: var(--color-primary-container);
color: var(--color-on-primary-container);
}
&.secondary {
background: var(--color-secondary-container);
color: var(--color-on-secondary-container);
}
h5 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
}
p {
margin: 0;
opacity: 0.8;
}
}
}
// Surface Styles
.surface-group {
display: flex;
flex-direction: column;
gap: 1rem;
.demo-surface {
padding: 1rem;
border-radius: 0.5rem;
text-align: center;
font-weight: 600;
&.surface-low {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.surface-container {
background: var(--color-surface-container);
color: var(--color-on-surface);
}
&.surface-high {
background: var(--color-surface-high);
color: var(--color-on-surface);
}
}
}
}
}
}
// ==========================================================================
// RESPONSIVE ADJUSTMENTS
// ==========================================================================
@media (max-width: 768px) {
.hcl-studio-demo {
padding: 1rem;
}
.demo-header {
padding: 1.5rem;
h1 {
font-size: 2rem;
}
p {
font-size: 1rem;
}
}
.theme-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.custom-inputs {
grid-template-columns: 1fr !important;
}
.custom-actions {
flex-direction: column;
}
}
// ==========================================================================
// TRANSITIONS FOR SMOOTH THEME CHANGES
// ==========================================================================
* {
transition: background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
opacity 0.3s ease;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HclStudioDemoComponent } from './hcl-studio-demo.component';
describe('HclStudioDemoComponent', () => {
let component: HclStudioDemoComponent;
let fixture: ComponentFixture<HclStudioDemoComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HclStudioDemoComponent]
})
.compileComponents();
fixture = TestBed.createComponent(HclStudioDemoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,288 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import {
HCLStudioService,
DEFAULT_THEMES,
ThemePreview,
BrandColors,
HCLConverter,
PaletteGenerator
} from 'hcl-studio';
@Component({
selector: 'app-hcl-studio-demo',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="hcl-studio-demo">
<div class="demo-header">
<h1>HCL Studio Demo</h1>
<p>Dynamic theme switching using HCL color space</p>
</div>
<!-- Current Theme Info -->
<div class="current-theme-info" *ngIf="currentTheme">
<h3>Current Theme: {{ currentTheme.name }}</h3>
<div class="color-swatches">
<div class="swatch" [style.background]="currentTheme.primary">
<span>Primary</span>
</div>
<div class="swatch" [style.background]="currentTheme.secondary">
<span>Secondary</span>
</div>
<div class="swatch" [style.background]="currentTheme.tertiary">
<span>Tertiary</span>
</div>
</div>
</div>
<!-- Mode Toggle -->
<div class="mode-controls">
<button
class="mode-toggle-btn"
(click)="toggleMode()"
[class.dark]="isDarkMode"
>
{{ isDarkMode ? 'Switch to Light' : 'Switch to Dark' }}
</button>
</div>
<!-- Built-in Themes -->
<div class="themes-section">
<h3>Built-in Themes</h3>
<div class="theme-grid">
<div
*ngFor="let theme of availableThemes"
class="theme-card"
[class.active]="theme.id === currentTheme?.id"
(click)="switchTheme(theme.id)"
>
<div class="theme-preview">
<div class="color-strip" [style.background]="theme.primary"></div>
<div class="color-strip" [style.background]="theme.secondary"></div>
<div class="color-strip" [style.background]="theme.tertiary"></div>
</div>
<span class="theme-name">{{ theme.name }}</span>
</div>
</div>
</div>
<!-- Custom Color Input -->
<div class="custom-theme-section">
<h3>Create Custom Theme</h3>
<div class="custom-inputs">
<div class="color-input-group">
<label>Primary Color:</label>
<input
type="color"
[(ngModel)]="customColors.primary"
(change)="previewCustomTheme()"
>
<input
type="text"
[(ngModel)]="customColors.primary"
(input)="previewCustomTheme()"
placeholder="#6750A4"
>
</div>
<div class="color-input-group">
<label>Secondary Color:</label>
<input
type="color"
[(ngModel)]="customColors.secondary"
(change)="previewCustomTheme()"
>
<input
type="text"
[(ngModel)]="customColors.secondary"
(input)="previewCustomTheme()"
placeholder="#625B71"
>
</div>
<div class="color-input-group">
<label>Tertiary Color:</label>
<input
type="color"
[(ngModel)]="customColors.tertiary"
(change)="previewCustomTheme()"
>
<input
type="text"
[(ngModel)]="customColors.tertiary"
(input)="previewCustomTheme()"
placeholder="#7D5260"
>
</div>
</div>
<div class="custom-actions">
<button class="preview-btn" (click)="previewCustomTheme()">
Preview Theme
</button>
<button class="apply-btn" (click)="applyCustomTheme()">
Apply Theme
</button>
</div>
<!-- Color Preview -->
<div *ngIf="colorPreview" class="color-preview">
<h4>Generated Palette Preview</h4>
<div class="preview-swatches">
<div
*ngFor="let color of colorPreview | keyvalue"
class="preview-swatch"
[style.background]="color.value"
[style.color]="getContrastColor(color.value)"
>
{{ color.key }}
</div>
</div>
</div>
</div>
<!-- Demo Components -->
<div class="demo-components">
<h3>Theme Demo Components</h3>
<div class="component-showcase">
<!-- Buttons -->
<div class="showcase-section">
<h4>Buttons</h4>
<div class="button-group">
<button class="btn-primary">Primary Button</button>
<button class="btn-secondary">Secondary Button</button>
<button class="btn-tertiary">Tertiary Button</button>
</div>
</div>
<!-- Cards -->
<div class="showcase-section">
<h4>Cards</h4>
<div class="card-group">
<div class="demo-card primary">
<h5>Primary Card</h5>
<p>This card uses primary colors</p>
</div>
<div class="demo-card secondary">
<h5>Secondary Card</h5>
<p>This card uses secondary colors</p>
</div>
</div>
</div>
<!-- Surfaces -->
<div class="showcase-section">
<h4>Surface Variations</h4>
<div class="surface-group">
<div class="demo-surface surface-low">Surface Low</div>
<div class="demo-surface surface-container">Surface Container</div>
<div class="demo-surface surface-high">Surface High</div>
</div>
</div>
</div>
</div>
</div>
`,
styleUrls: ['./hcl-studio-demo.component.scss']
})
export class HclStudioDemoComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// State
availableThemes: ThemePreview[] = [];
currentTheme: ThemePreview | null = null;
isDarkMode = false;
// Custom theme inputs
customColors: BrandColors = {
primary: '#6750A4',
secondary: '#625B71',
tertiary: '#7D5260'
};
colorPreview: { [key: string]: string } | null = null;
constructor(private hclStudio: HCLStudioService) {}
ngOnInit(): void {
// Initialize HCL Studio with default themes
this.hclStudio.initialize({
themes: DEFAULT_THEMES,
defaultTheme: 'material-purple',
autoMode: false
});
// Subscribe to theme changes
this.hclStudio.themeState$
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.availableThemes = state.availableThemes;
if (state.currentTheme) {
this.currentTheme = {
id: state.currentTheme.config.id,
name: state.currentTheme.config.name,
description: state.currentTheme.config.description,
primary: state.currentTheme.config.colors.primary,
secondary: state.currentTheme.config.colors.secondary,
tertiary: state.currentTheme.config.colors.tertiary
};
}
});
// Subscribe to mode changes
this.hclStudio.currentMode$
.pipe(takeUntil(this.destroy$))
.subscribe(mode => {
this.isDarkMode = mode === 'dark';
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
switchTheme(themeId: string): void {
this.hclStudio.switchTheme(themeId);
}
toggleMode(): void {
this.hclStudio.toggleMode();
}
previewCustomTheme(): void {
try {
this.colorPreview = this.hclStudio.generateColorPreview(this.customColors);
} catch (error) {
console.warn('Failed to generate color preview:', error);
this.colorPreview = null;
}
}
applyCustomTheme(): void {
const customTheme = this.hclStudio.createCustomTheme(
'custom-theme',
'My Custom Theme',
this.customColors,
'User-created custom theme'
);
this.hclStudio.switchTheme('custom-theme');
}
getContrastColor(backgroundColor: string): string {
try {
const ratio1 = HCLConverter.getContrastRatio(backgroundColor, '#000000');
const ratio2 = HCLConverter.getContrastRatio(backgroundColor, '#FFFFFF');
return ratio1 > ratio2 ? '#000000' : '#FFFFFF';
} catch {
return '#000000';
}
}
}

View File

@@ -0,0 +1,494 @@
@use '../../../../../ui-design-system/src/styles/semantic' as *;
.demo-container {
padding: $semantic-spacing-component-lg;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
margin-bottom: $semantic-spacing-layout-section-md;
h2 {
font-family: map-get($semantic-typography-heading-h2, font-family);
font-size: map-get($semantic-typography-heading-h2, font-size);
font-weight: map-get($semantic-typography-heading-h2, font-weight);
line-height: map-get($semantic-typography-heading-h2, line-height);
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-content-heading;
}
h3 {
font-family: map-get($semantic-typography-heading-h3, font-family);
font-size: map-get($semantic-typography-heading-h3, font-size);
font-weight: map-get($semantic-typography-heading-h3, font-weight);
line-height: map-get($semantic-typography-heading-h3, line-height);
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-md;
}
}
.demo-showcase {
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-md;
background: $semantic-color-surface-secondary;
padding: $semantic-spacing-component-md;
height: 400px;
position: relative;
}
.demo-scroll-container {
width: 100%;
height: 100%;
border-radius: $semantic-border-radius-sm;
background: $semantic-color-surface-primary;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
}
// Basic demo items
.demo-item {
padding: $semantic-spacing-component-md;
margin-bottom: $semantic-spacing-component-sm;
background: $semantic-color-surface-elevated;
border-radius: $semantic-border-radius-sm;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
h4 {
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-primary;
margin: 0 0 $semantic-spacing-content-line-normal 0;
}
p {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-secondary;
margin: 0 0 $semantic-spacing-component-xs 0;
}
small {
font-family: map-get($semantic-typography-caption, font-family);
font-size: map-get($semantic-typography-caption, font-size);
font-weight: map-get($semantic-typography-caption, font-weight);
line-height: map-get($semantic-typography-caption, line-height);
color: $semantic-color-text-tertiary;
}
}
// Template demo items
.demo-template-item {
padding: $semantic-spacing-component-lg;
background: $semantic-color-surface-elevated;
border-radius: $semantic-border-radius-md;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:hover {
box-shadow: $semantic-shadow-elevation-2;
transform: translateY(-2px);
}
&--last {
border-color: $semantic-color-primary;
box-shadow: $semantic-shadow-elevation-1;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $semantic-spacing-component-sm;
}
&__id {
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: $semantic-typography-font-weight-semibold;
line-height: map-get($semantic-typography-body-small, line-height);
color: $semantic-color-primary;
}
&__category {
font-family: map-get($semantic-typography-caption, font-family);
font-size: map-get($semantic-typography-caption, font-size);
font-weight: map-get($semantic-typography-caption, font-weight);
line-height: map-get($semantic-typography-caption, line-height);
background: $semantic-color-surface-container;
color: $semantic-color-text-secondary;
padding: $semantic-spacing-1 $semantic-spacing-2;
border-radius: $semantic-border-radius-sm;
}
&__title {
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-primary;
margin: 0 0 $semantic-spacing-component-xs 0;
}
&__description {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-secondary;
margin: 0 0 $semantic-spacing-component-sm 0;
}
&__footer {
border-top: $semantic-border-width-1 solid $semantic-color-border-subtle;
padding-top: $semantic-spacing-component-xs;
small {
font-family: map-get($semantic-typography-caption, font-family);
font-size: map-get($semantic-typography-caption, font-size);
font-weight: map-get($semantic-typography-caption, font-weight);
line-height: map-get($semantic-typography-caption, line-height);
color: $semantic-color-text-tertiary;
}
}
}
// Bidirectional demo items
.demo-bidirectional-item {
padding: $semantic-spacing-component-sm;
&__content {
background: $semantic-color-surface-elevated;
padding: $semantic-spacing-component-md;
border-radius: $semantic-border-radius-lg;
border-left: 4px solid $semantic-color-primary;
strong {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: $semantic-typography-font-weight-semibold;
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-primary;
display: block;
margin-bottom: $semantic-spacing-content-line-tight;
}
p {
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
color: $semantic-color-text-secondary;
margin: 0 0 $semantic-spacing-content-line-tight 0;
}
small {
font-family: map-get($semantic-typography-caption, font-family);
font-size: map-get($semantic-typography-caption, font-size);
font-weight: map-get($semantic-typography-caption, font-weight);
line-height: map-get($semantic-typography-caption, line-height);
color: $semantic-color-text-tertiary;
}
}
}
// Custom templates
.demo-custom-item {
display: flex;
gap: $semantic-spacing-component-md;
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-elevated;
border-radius: $semantic-border-radius-md;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
&__avatar {
flex-shrink: 0;
width: $semantic-sizing-touch-target;
height: $semantic-sizing-touch-target;
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: $semantic-typography-font-weight-bold;
line-height: map-get($semantic-typography-body-medium, line-height);
}
&__content {
flex: 1;
min-width: 0;
h4 {
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-primary;
margin: 0 0 $semantic-spacing-content-line-tight 0;
}
p {
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
color: $semantic-color-text-secondary;
margin: 0 0 $semantic-spacing-content-line-tight 0;
}
}
&__time {
font-family: map-get($semantic-typography-caption, font-family);
font-size: map-get($semantic-typography-caption, font-size);
font-weight: map-get($semantic-typography-caption, font-weight);
line-height: map-get($semantic-typography-caption, line-height);
color: $semantic-color-text-tertiary;
}
}
.demo-custom-loading {
display: flex;
align-items: center;
gap: $semantic-spacing-component-md;
padding: $semantic-spacing-component-lg;
background: linear-gradient(45deg, $semantic-color-primary, $semantic-color-secondary);
color: $semantic-color-on-primary;
border-radius: $semantic-border-radius-lg;
&__spinner {
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin $semantic-motion-duration-slow linear infinite;
}
span {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: $semantic-typography-font-weight-medium;
line-height: map-get($semantic-typography-body-medium, line-height);
}
}
.demo-custom-error {
text-align: center;
padding: $semantic-spacing-component-xl;
background: rgba($semantic-color-danger, 0.1);
border: $semantic-border-width-1 solid $semantic-color-danger;
border-radius: $semantic-border-radius-lg;
&__icon {
font-size: $semantic-typography-font-size-2xl;
margin-bottom: $semantic-spacing-component-sm;
}
h4 {
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-danger;
margin: 0 0 $semantic-spacing-component-xs 0;
}
p {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-secondary;
margin: 0;
}
}
.demo-custom-end {
text-align: center;
padding: $semantic-spacing-component-xl;
background: linear-gradient(135deg, $semantic-color-success, $semantic-color-primary);
color: $semantic-color-on-success;
border-radius: $semantic-border-radius-lg;
&__icon {
font-size: $semantic-typography-font-size-3xl;
margin-bottom: $semantic-spacing-component-sm;
}
h4 {
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
margin: 0 0 $semantic-spacing-component-xs 0;
}
p {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
margin: 0;
opacity: 0.9;
}
}
// Configuration demo
.demo-config-item {
display: flex;
align-items: center;
gap: $semantic-spacing-component-md;
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-elevated;
border-radius: $semantic-border-radius-sm;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
&__index {
flex-shrink: 0;
width: $semantic-sizing-touch-minimum;
height: $semantic-sizing-touch-minimum;
background: $semantic-color-surface-container;
color: $semantic-color-text-secondary;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: $semantic-typography-font-weight-semibold;
line-height: map-get($semantic-typography-body-small, line-height);
}
&__content {
flex: 1;
h4 {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: $semantic-typography-font-weight-semibold;
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-primary;
margin: 0 0 $semantic-spacing-content-line-tight 0;
}
p {
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
color: $semantic-color-text-secondary;
margin: 0;
}
}
}
// Controls
.demo-controls {
display: flex;
flex-wrap: wrap;
gap: $semantic-spacing-component-md;
margin-bottom: $semantic-spacing-component-lg;
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-secondary;
border-radius: $semantic-border-radius-md;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
button {
padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x;
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border: none;
border-radius: $semantic-border-radius-sm;
cursor: pointer;
font-family: map-get($semantic-typography-button-medium, font-family);
font-size: map-get($semantic-typography-button-medium, font-size);
font-weight: map-get($semantic-typography-button-medium, font-weight);
line-height: map-get($semantic-typography-button-medium, line-height);
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:hover {
box-shadow: $semantic-shadow-elevation-2;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
box-shadow: $semantic-shadow-elevation-1;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
}
}
.demo-control {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-xs;
label {
font-family: map-get($semantic-typography-label, font-family);
font-size: map-get($semantic-typography-label, font-size);
font-weight: map-get($semantic-typography-label, font-weight);
line-height: map-get($semantic-typography-label, line-height);
color: $semantic-color-text-primary;
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-xs;
}
input[type="checkbox"] {
width: auto;
margin-right: $semantic-spacing-component-xs;
}
input[type="range"] {
width: 150px;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
.demo-template-item {
transition: none;
&:hover {
transform: none;
}
}
.demo-custom-loading__spinner {
animation: none;
}
button {
transition: none;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}

View File

@@ -0,0 +1,501 @@
import { Component, TemplateRef, ViewChild, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { InfiniteScrollContainerComponent, InfiniteScrollEvent, InfiniteScrollConfig } from 'ui-essentials';
interface DemoItem {
id: number;
title: string;
description: string;
timestamp: Date;
category: string;
}
@Component({
selector: 'ui-infinite-scroll-container-demo',
standalone: true,
imports: [CommonModule, InfiniteScrollContainerComponent],
template: `
<div class="demo-container">
<h2>Infinite Scroll Container Demo</h2>
<!-- Basic Example -->
<section class="demo-section">
<h3>Basic Infinite Scroll (Content Projection)</h3>
<div class="demo-showcase">
<ui-infinite-scroll-container
class="demo-scroll-container"
[hasMore]="basicHasMore()"
[loading]="basicLoading()"
(loadMore)="loadMoreBasic($event)">
@for (item of basicItems(); track item.id) {
<div class="demo-item">
<h4>{{ item.title }}</h4>
<p>{{ item.description }}</p>
<small>{{ item.timestamp | date:'short' }} - {{ item.category }}</small>
</div>
}
</ui-infinite-scroll-container>
</div>
</section>
<!-- Template-Based Example -->
<section class="demo-section">
<h3>Template-Based Rendering</h3>
<div class="demo-showcase">
<ui-infinite-scroll-container
class="demo-scroll-container"
[items]="templateItems()"
[itemTemplate]="itemTemplate"
[trackByFn]="trackByFn"
[hasMore]="templateHasMore()"
[loading]="templateLoading()"
[config]="templateConfig"
(loadMore)="loadMoreTemplate($event)">
</ui-infinite-scroll-container>
<ng-template #itemTemplate let-item="item" let-index="index" let-isLast="isLast">
<div class="demo-template-item" [class.demo-template-item--last]="isLast">
<div class="demo-template-item__header">
<span class="demo-template-item__id">#{{ item.id }}</span>
<span class="demo-template-item__category">{{ item.category }}</span>
</div>
<h4 class="demo-template-item__title">{{ item.title }}</h4>
<p class="demo-template-item__description">{{ item.description }}</p>
<div class="demo-template-item__footer">
<small>Index: {{ index }} | {{ item.timestamp | date:'short' }}</small>
</div>
</div>
</ng-template>
</div>
</section>
<!-- Bidirectional Scrolling -->
<section class="demo-section">
<h3>Bidirectional Infinite Scroll</h3>
<div class="demo-showcase">
<ui-infinite-scroll-container
class="demo-scroll-container"
direction="both"
[items]="bidirectionalItems()"
[itemTemplate]="bidirectionalTemplate"
[hasMore]="bidirectionalHasMore()"
[loadingUp]="bidirectionalLoadingUp()"
[loadingDown]="bidirectionalLoadingDown()"
[config]="bidirectionalConfig"
(loadMore)="loadMoreBidirectional($event)">
</ui-infinite-scroll-container>
<ng-template #bidirectionalTemplate let-item="item" let-index="index">
<div class="demo-bidirectional-item">
<div class="demo-bidirectional-item__content">
<strong>Message {{ item.id }}</strong>
<p>{{ item.description }}</p>
<small>{{ item.timestamp | date:'short' }}</small>
</div>
</div>
</ng-template>
</div>
</section>
<!-- Custom Templates -->
<section class="demo-section">
<h3>Custom Loading & Error Templates</h3>
<div class="demo-showcase">
<ui-infinite-scroll-container
class="demo-scroll-container"
[items]="customItems()"
[itemTemplate]="customItemTemplate"
[loadingTemplate]="customLoadingTemplate"
[errorTemplate]="customErrorTemplate"
[endTemplate]="customEndTemplate"
[hasMore]="customHasMore()"
[loading]="customLoading()"
[error]="customError()"
[config]="customConfig"
(loadMore)="loadMoreCustom($event)"
(retryRequested)="retryCustom()">
</ui-infinite-scroll-container>
<ng-template #customItemTemplate let-item="item">
<div class="demo-custom-item">
<div class="demo-custom-item__avatar">
{{ item.title.charAt(0) }}
</div>
<div class="demo-custom-item__content">
<h4>{{ item.title }}</h4>
<p>{{ item.description }}</p>
<span class="demo-custom-item__time">{{ getTimeAgo(item.timestamp) }}</span>
</div>
</div>
</ng-template>
<ng-template #customLoadingTemplate>
<div class="demo-custom-loading">
<div class="demo-custom-loading__spinner"></div>
<span>Loading amazing content...</span>
</div>
</ng-template>
<ng-template #customErrorTemplate let-error="error">
<div class="demo-custom-error">
<div class="demo-custom-error__icon">⚠️</div>
<h4>Oops! Something went wrong</h4>
<p>{{ error }}</p>
</div>
</ng-template>
<ng-template #customEndTemplate>
<div class="demo-custom-end">
<div class="demo-custom-end__icon">🎉</div>
<h4>That's all folks!</h4>
<p>You've reached the end of our amazing content.</p>
</div>
</ng-template>
</div>
</section>
<!-- Configuration Examples -->
<section class="demo-section">
<h3>Configuration Options</h3>
<div class="demo-controls">
<div class="demo-control">
<label>
<input type="checkbox" [checked]="disableScroll()" (change)="toggleDisabled($event)">
Disable Infinite Scroll
</label>
</div>
<div class="demo-control">
<label>
Threshold: {{ threshold() }}px
<input
type="range"
min="50"
max="500"
[value]="threshold()"
(input)="updateThreshold($event)">
</label>
</div>
<div class="demo-control">
<label>
Debounce Time: {{ debounceTime() }}ms
<input
type="range"
min="50"
max="1000"
[value]="debounceTime()"
(input)="updateDebounceTime($event)">
</label>
</div>
</div>
<div class="demo-showcase">
<ui-infinite-scroll-container
class="demo-scroll-container"
[items]="configItems()"
[itemTemplate]="configItemTemplate"
[hasMore]="configHasMore()"
[loading]="configLoading()"
[config]="currentConfig()"
(loadMore)="loadMoreConfig($event)">
</ui-infinite-scroll-container>
<ng-template #configItemTemplate let-item="item" let-index="index">
<div class="demo-config-item">
<span class="demo-config-item__index">{{ index + 1 }}</span>
<div class="demo-config-item__content">
<h4>{{ item.title }}</h4>
<p>{{ item.description }}</p>
</div>
</div>
</ng-template>
</div>
</section>
<!-- API Methods Demo -->
<section class="demo-section">
<h3>API Methods</h3>
<div class="demo-controls">
<button type="button" (click)="scrollToTop()">Scroll to Top</button>
<button type="button" (click)="scrollToBottom()">Scroll to Bottom</button>
<button type="button" (click)="scrollToIndex(50)">Scroll to Item 50</button>
<button type="button" (click)="resetApiScroll()">Reset Scroll</button>
</div>
</section>
</div>
`,
styleUrl: './infinite-scroll-container-demo.component.scss'
})
export class InfiniteScrollContainerDemoComponent {
@ViewChild('itemTemplate') itemTemplate!: TemplateRef<any>;
// Basic example
basicItems = signal<DemoItem[]>([]);
basicLoading = signal(false);
basicHasMore = signal(true);
private basicPage = 1;
// Template example
templateItems = signal<DemoItem[]>([]);
templateLoading = signal(false);
templateHasMore = signal(true);
private templatePage = 1;
templateConfig: InfiniteScrollConfig = {
threshold: 100,
debounceTime: 150,
itemHeight: 120
};
// Bidirectional example
bidirectionalItems = signal<DemoItem[]>([]);
bidirectionalLoadingUp = signal(false);
bidirectionalLoadingDown = signal(false);
bidirectionalHasMore = signal(true);
private bidirectionalTopPage = 0;
private bidirectionalBottomPage = 1;
bidirectionalConfig: InfiniteScrollConfig = {
threshold: 50,
debounceTime: 200
};
// Custom templates example
customItems = signal<DemoItem[]>([]);
customLoading = signal(false);
customHasMore = signal(true);
customError = signal<string | undefined>(undefined);
private customPage = 1;
private customFailCount = 0;
customConfig: InfiniteScrollConfig = {
threshold: 150,
debounceTime: 100
};
// Configuration example
configItems = signal<DemoItem[]>([]);
configLoading = signal(false);
configHasMore = signal(true);
private configPage = 1;
// Configuration controls
threshold = signal(200);
debounceTime = signal(300);
disableScroll = signal(false);
currentConfig = signal<InfiniteScrollConfig>({
threshold: 200,
debounceTime: 300,
disabled: false
});
constructor() {
// Initialize with some data
this.loadInitialData();
}
// Track by function for template rendering
trackByFn = (index: number, item: DemoItem) => item.id;
// Basic example methods
loadMoreBasic(event: InfiniteScrollEvent): void {
if (this.basicLoading()) return;
this.basicLoading.set(true);
// Simulate API call
setTimeout(() => {
const newItems = this.generateItems(this.basicPage * 20, 20, 'basic');
this.basicItems.update(items => [...items, ...newItems]);
this.basicPage++;
this.basicLoading.set(false);
// Stop loading after 5 pages
if (this.basicPage > 5) {
this.basicHasMore.set(false);
}
}, 1000);
}
// Template example methods
loadMoreTemplate(event: InfiniteScrollEvent): void {
if (this.templateLoading()) return;
this.templateLoading.set(true);
setTimeout(() => {
const newItems = this.generateItems(this.templatePage * 15, 15, 'template');
this.templateItems.update(items => [...items, ...newItems]);
this.templatePage++;
this.templateLoading.set(false);
if (this.templatePage > 6) {
this.templateHasMore.set(false);
}
}, 800);
}
// Bidirectional example methods
loadMoreBidirectional(event: InfiniteScrollEvent): void {
if (event.direction === 'down') {
this.loadMoreBidirectionalDown();
} else {
this.loadMoreBidirectionalUp();
}
}
private loadMoreBidirectionalDown(): void {
if (this.bidirectionalLoadingDown()) return;
this.bidirectionalLoadingDown.set(true);
setTimeout(() => {
const newItems = this.generateItems(this.bidirectionalBottomPage * 10, 10, 'chat');
this.bidirectionalItems.update(items => [...items, ...newItems]);
this.bidirectionalBottomPage++;
this.bidirectionalLoadingDown.set(false);
}, 600);
}
private loadMoreBidirectionalUp(): void {
if (this.bidirectionalLoadingUp()) return;
this.bidirectionalLoadingUp.set(true);
setTimeout(() => {
const startId = this.bidirectionalTopPage * 10;
const newItems = this.generateItems(startId, 10, 'history');
this.bidirectionalItems.update(items => [...newItems, ...items]);
this.bidirectionalTopPage--;
this.bidirectionalLoadingUp.set(false);
}, 800);
}
// Custom templates example methods
loadMoreCustom(event: InfiniteScrollEvent): void {
if (this.customLoading()) return;
this.customLoading.set(true);
this.customError.set(undefined);
// Simulate occasional failures
const shouldFail = Math.random() < 0.3 && this.customFailCount < 2;
setTimeout(() => {
if (shouldFail) {
this.customError.set('Network error: Failed to load more items');
this.customLoading.set(false);
this.customFailCount++;
} else {
const newItems = this.generateItems(this.customPage * 12, 12, 'custom');
this.customItems.update(items => [...items, ...newItems]);
this.customPage++;
this.customLoading.set(false);
if (this.customPage > 4) {
this.customHasMore.set(false);
}
}
}, 1200);
}
retryCustom(): void {
this.loadMoreCustom({ direction: 'down', currentIndex: 0, scrollPosition: 0 });
}
// Configuration example methods
loadMoreConfig(event: InfiniteScrollEvent): void {
if (this.configLoading()) return;
this.configLoading.set(true);
setTimeout(() => {
const newItems = this.generateItems(this.configPage * 10, 10, 'config');
this.configItems.update(items => [...items, ...newItems]);
this.configPage++;
this.configLoading.set(false);
if (this.configPage > 10) {
this.configHasMore.set(false);
}
}, 500);
}
// Configuration controls
toggleDisabled(event: Event): void {
const disabled = (event.target as HTMLInputElement).checked;
this.disableScroll.set(disabled);
this.updateCurrentConfig();
}
updateThreshold(event: Event): void {
const value = parseInt((event.target as HTMLInputElement).value);
this.threshold.set(value);
this.updateCurrentConfig();
}
updateDebounceTime(event: Event): void {
const value = parseInt((event.target as HTMLInputElement).value);
this.debounceTime.set(value);
this.updateCurrentConfig();
}
private updateCurrentConfig(): void {
this.currentConfig.set({
threshold: this.threshold(),
debounceTime: this.debounceTime(),
disabled: this.disableScroll()
});
}
// API methods - These would be called on component references
scrollToTop(): void {
console.log('Scroll to top - would call component method');
}
scrollToBottom(): void {
console.log('Scroll to bottom - would call component method');
}
scrollToIndex(index: number): void {
console.log(`Scroll to index ${index} - would call component method`);
}
resetApiScroll(): void {
console.log('Reset scroll - would call component method');
}
// Utility methods
getTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`;
return `${Math.floor(minutes / 1440)}d ago`;
}
private loadInitialData(): void {
// Load initial data for all examples
this.basicItems.set(this.generateItems(1, 20, 'basic'));
this.templateItems.set(this.generateItems(1, 15, 'template'));
this.bidirectionalItems.set(this.generateItems(1, 20, 'chat'));
this.customItems.set(this.generateItems(1, 12, 'custom'));
this.configItems.set(this.generateItems(1, 10, 'config'));
}
private generateItems(startId: number, count: number, type: string): DemoItem[] {
const categories = ['Technology', 'Science', 'Arts', 'Sports', 'Travel', 'Food', 'Music', 'Literature'];
return Array.from({ length: count }, (_, i) => ({
id: startId + i,
title: `${type.charAt(0).toUpperCase() + type.slice(1)} Item ${startId + i}`,
description: `This is a sample description for ${type} item ${startId + i}. It contains some interesting content that demonstrates the infinite scroll functionality.`,
timestamp: new Date(Date.now() - Math.random() * 86400000 * 30), // Random date within last 30 days
category: categories[Math.floor(Math.random() * categories.length)]
}));
}
}

View File

@@ -0,0 +1,128 @@
@use "../../../../../ui-design-system/src/styles/semantic" as *;
.demo-container {
padding: $semantic-spacing-component-lg;
max-width: 100%;
h2 {
margin: 0 0 $semantic-spacing-component-lg 0;
font-family: map-get($semantic-typography-heading-h2, font-family);
font-size: map-get($semantic-typography-heading-h2, font-size);
font-weight: map-get($semantic-typography-heading-h2, font-weight);
line-height: map-get($semantic-typography-heading-h2, line-height);
color: $semantic-color-text-primary;
}
h3 {
margin: $semantic-spacing-component-xl 0 $semantic-spacing-component-md 0;
font-family: map-get($semantic-typography-heading-h3, font-family);
font-size: map-get($semantic-typography-heading-h3, font-size);
font-weight: map-get($semantic-typography-heading-h3, font-weight);
line-height: map-get($semantic-typography-heading-h3, line-height);
color: $semantic-color-text-primary;
}
h4 {
margin: 0 0 $semantic-spacing-component-sm 0;
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-primary;
}
}
.demo-section {
margin-bottom: $semantic-spacing-component-xl;
&:last-child {
margin-bottom: 0;
}
}
.demo-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: $semantic-spacing-grid-gap-lg;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: $semantic-spacing-grid-gap-md;
}
}
.demo-item {
background: $semantic-color-surface-secondary;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
border-radius: $semantic-border-radius-md;
padding: $semantic-spacing-component-md;
box-shadow: $semantic-shadow-elevation-1;
}
.event-log {
margin-top: $semantic-spacing-component-lg;
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-secondary;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
border-radius: $semantic-border-radius-md;
h4 {
margin: 0 0 $semantic-spacing-component-sm 0;
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-primary;
}
}
.events {
max-height: 200px;
overflow-y: auto;
// Scroll styling
scrollbar-width: thin;
scrollbar-color: $semantic-color-border-primary $semantic-color-surface-container;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: $semantic-color-surface-container;
border-radius: $semantic-border-radius-sm;
}
&::-webkit-scrollbar-thumb {
background: $semantic-color-border-primary;
border-radius: $semantic-border-radius-sm;
}
}
.event-item {
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-xs;
padding: $semantic-spacing-component-sm;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
&:last-child {
border-bottom: none;
}
strong {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: $semantic-typography-font-weight-semibold;
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-primary;
}
small {
font-family: map-get($semantic-typography-caption, font-family);
font-size: map-get($semantic-typography-caption, font-size);
font-weight: map-get($semantic-typography-caption, font-weight);
line-height: map-get($semantic-typography-caption, line-height);
color: $semantic-color-text-tertiary;
}
}

View File

@@ -0,0 +1,354 @@
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { KanbanBoardComponent, KanbanColumn, KanbanItem, KanbanDragEvent } from 'ui-essentials';
@Component({
selector: 'ui-kanban-board-demo',
standalone: true,
imports: [CommonModule, KanbanBoardComponent],
template: `
<div class="demo-container">
<h2>Kanban Board Layout Demo</h2>
<!-- Size Variants -->
<section class="demo-section">
<h3>Sizes</h3>
<div class="demo-row">
<div class="demo-item">
<h4>Small</h4>
<div style="height: 400px; width: 100%;">
<ui-kanban-board
[columns]="basicColumns()"
size="sm"
title="Small Kanban Board"
(itemMoved)="onItemMoved($event)"
(itemClicked)="onItemClicked($event)">
</ui-kanban-board>
</div>
</div>
<div class="demo-item">
<h4>Medium (Default)</h4>
<div style="height: 400px; width: 100%;">
<ui-kanban-board
[columns]="basicColumns()"
size="md"
title="Medium Kanban Board"
(itemMoved)="onItemMoved($event)"
(itemClicked)="onItemClicked($event)">
</ui-kanban-board>
</div>
</div>
<div class="demo-item">
<h4>Large</h4>
<div style="height: 400px; width: 100%;">
<ui-kanban-board
[columns]="basicColumns()"
size="lg"
title="Large Kanban Board"
(itemMoved)="onItemMoved($event)"
(itemClicked)="onItemClicked($event)">
</ui-kanban-board>
</div>
</div>
</div>
</section>
<!-- Full Featured Example -->
<section class="demo-section">
<h3>Full Featured Kanban Board</h3>
<div style="height: 600px; width: 100%;">
<ui-kanban-board
[columns]="fullColumns()"
title="Project Management Board"
subtitle="Drag and drop items between columns to update their status"
size="md"
(itemMoved)="onItemMoved($event)"
(itemClicked)="onItemClicked($event)">
</ui-kanban-board>
</div>
</section>
<!-- States -->
<section class="demo-section">
<h3>States</h3>
<div class="demo-row">
<div class="demo-item">
<h4>Disabled</h4>
<div style="height: 300px; width: 100%;">
<ui-kanban-board
[columns]="basicColumns()"
[disabled]="true"
title="Disabled Kanban Board">
</ui-kanban-board>
</div>
</div>
<div class="demo-item">
<h4>Column with Limits</h4>
<div style="height: 300px; width: 100%;">
<ui-kanban-board
[columns]="limitedColumns()"
title="Limited Capacity Board"
(itemMoved)="onItemMoved($event)">
</ui-kanban-board>
</div>
</div>
</div>
</section>
<!-- Interactive Example -->
<section class="demo-section">
<h3>Interactive Events</h3>
<div style="height: 500px; width: 100%;">
<ui-kanban-board
[columns]="interactiveColumns()"
title="Interactive Kanban Board"
subtitle="Click items or drag them around to see event logging below"
(itemMoved)="onItemMoved($event)"
(itemClicked)="onItemClicked($event)">
</ui-kanban-board>
</div>
<div class="event-log">
<h4>Event Log:</h4>
<div class="events">
@for (event of eventLog(); track event.id) {
<div class="event-item">
<strong>{{ event.type }}:</strong> {{ event.message }}
<small>{{ event.timestamp | date:'medium' }}</small>
</div>
}
</div>
</div>
</section>
</div>
`,
styleUrl: './kanban-board-demo.component.scss'
})
export class KanbanBoardDemoComponent {
eventLog = signal<Array<{ id: string; type: string; message: string; timestamp: Date }>>([]);
basicColumns(): KanbanColumn[] {
return [
{
id: 'todo',
title: 'To Do',
items: [
{ id: '1', title: 'Design homepage', description: 'Create wireframes and mockups' },
{ id: '2', title: 'Setup database', priority: 'high' }
]
},
{
id: 'in-progress',
title: 'In Progress',
items: [
{ id: '3', title: 'Build API endpoints', priority: 'medium', assignee: 'John' }
]
},
{
id: 'done',
title: 'Done',
items: [
{ id: '4', title: 'Project setup', description: 'Initialize repository and dependencies' }
]
}
];
}
fullColumns(): KanbanColumn[] {
return [
{
id: 'backlog',
title: 'Backlog',
items: [
{
id: 'item-1',
title: 'User Authentication System',
description: 'Implement login, registration, and password reset functionality',
priority: 'high',
tags: ['backend', 'security', 'user-management'],
assignee: 'Sarah Connor',
dueDate: new Date(2024, 11, 15)
},
{
id: 'item-2',
title: 'Dashboard Analytics',
description: 'Create comprehensive analytics dashboard with charts and metrics',
priority: 'medium',
tags: ['frontend', 'analytics', 'charts'],
assignee: 'John Doe',
dueDate: new Date(2024, 11, 20)
},
{
id: 'item-3',
title: 'Mobile Responsiveness',
description: 'Ensure all pages work correctly on mobile devices',
priority: 'low',
tags: ['frontend', 'responsive', 'css'],
assignee: 'Jane Smith'
}
]
},
{
id: 'todo',
title: 'To Do',
items: [
{
id: 'item-4',
title: 'API Rate Limiting',
description: 'Implement rate limiting for API endpoints to prevent abuse',
priority: 'high',
tags: ['backend', 'security', 'performance'],
assignee: 'Bob Wilson',
dueDate: new Date(2024, 11, 10)
},
{
id: 'item-5',
title: 'Email Notifications',
description: 'Set up email notification system for important events',
priority: 'medium',
tags: ['backend', 'email', 'notifications'],
assignee: 'Alice Brown'
}
]
},
{
id: 'in-progress',
title: 'In Progress',
items: [
{
id: 'item-6',
title: 'Database Optimization',
description: 'Optimize database queries and add proper indexing',
priority: 'high',
tags: ['database', 'performance', 'optimization'],
assignee: 'Mike Johnson',
dueDate: new Date(2024, 11, 8)
}
]
},
{
id: 'review',
title: 'In Review',
items: [
{
id: 'item-7',
title: 'Unit Test Coverage',
description: 'Increase unit test coverage to 90%',
priority: 'medium',
tags: ['testing', 'quality', 'coverage'],
assignee: 'Lisa Davis',
dueDate: new Date(2024, 11, 12)
}
]
},
{
id: 'done',
title: 'Done',
items: [
{
id: 'item-8',
title: 'Project Setup',
description: 'Initialize project structure and dependencies',
priority: 'high',
tags: ['setup', 'infrastructure'],
assignee: 'Team Lead'
},
{
id: 'item-9',
title: 'Design System',
description: 'Create component library and design tokens',
priority: 'medium',
tags: ['design', 'components', 'ui'],
assignee: 'Design Team'
}
]
}
];
}
limitedColumns(): KanbanColumn[] {
return [
{
id: 'todo',
title: 'To Do',
items: [
{ id: '1', title: 'Task 1' },
{ id: '2', title: 'Task 2' }
]
},
{
id: 'limited',
title: 'Limited (Max 2)',
maxItems: 2,
items: [
{ id: '3', title: 'Limited Task 1' },
{ id: '4', title: 'Limited Task 2' }
]
},
{
id: 'done',
title: 'Done',
items: []
}
];
}
interactiveColumns(): KanbanColumn[] {
return [
{
id: 'new',
title: 'New',
items: [
{ id: 'i1', title: 'Interactive Item 1', description: 'Click me or drag me!' },
{ id: 'i2', title: 'Interactive Item 2', priority: 'high', assignee: 'You' }
]
},
{
id: 'active',
title: 'Active',
items: [
{ id: 'i3', title: 'Interactive Item 3', priority: 'medium' }
]
},
{
id: 'complete',
title: 'Complete',
items: []
}
];
}
onItemMoved(event: KanbanDragEvent): void {
this.logEvent('Item Moved',
`"${event.item.title}" moved from "${event.fromColumn}" to "${event.toColumn}"`
);
// In a real application, you would update your data here
console.log('Item moved:', event);
}
onItemClicked(event: { item: KanbanItem; columnId: string }): void {
this.logEvent('Item Clicked',
`"${event.item.title}" clicked in column "${event.columnId}"`
);
console.log('Item clicked:', event);
}
private logEvent(type: string, message: string): void {
const currentLog = this.eventLog();
const newEvent = {
id: Date.now().toString(),
type,
message,
timestamp: new Date()
};
// Keep only the last 10 events
const updatedLog = [newEvent, ...currentLog].slice(0, 10);
this.eventLog.set(updatedLog);
}
}

View File

@@ -0,0 +1,30 @@
.demo-container {
.demo-section {
margin-bottom: 2rem;
h3 {
margin-bottom: 1rem;
color: #333;
}
.demo-row {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
.demo-item {
&.full-width {
grid-column: 1 / -1;
}
h4 {
margin-bottom: 0.5rem;
color: #555;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
}
}

View File

@@ -0,0 +1,225 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MasonryComponent } from 'ui-essentials';
@Component({
selector: 'ui-masonry-demo',
standalone: true,
imports: [CommonModule, MasonryComponent],
template: `
<div class="demo-container">
<h2>Masonry Layout Demo</h2>
<p>Pinterest-style dynamic grid layout that arranges items in columns with optimal vertical spacing.</p>
<!-- Column Count Variants -->
<section class="demo-section">
<h3>Column Count</h3>
<div class="demo-row">
@for (columns of columnCounts; track columns) {
<div class="demo-item">
<h4>{{ columns }} Columns</h4>
<ui-masonry [columns]="columns" gap="sm" padding="sm">
@for (item of sampleItems.slice(0, 8); track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
}
</div>
</section>
<!-- Auto Responsive Variants -->
<section class="demo-section">
<h3>Auto Responsive</h3>
<div class="demo-row">
<div class="demo-item">
<h4>Auto Fit (250px min width)</h4>
<ui-masonry columns="auto-fit" gap="md" padding="md">
@for (item of sampleItems.slice(0, 12); track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
<div class="demo-item">
<h4>Auto Fill (200px min width)</h4>
<ui-masonry columns="auto-fill" gap="md" padding="md">
@for (item of sampleItems.slice(0, 12); track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
</div>
</section>
<!-- Gap Variants -->
<section class="demo-section">
<h3>Gap Sizes</h3>
<div class="demo-row">
@for (gap of gaps; track gap) {
<div class="demo-item">
<h4>Gap: {{ gap }}</h4>
<ui-masonry [columns]="3" [gap]="gap" padding="sm">
@for (item of sampleItems.slice(0, 6); track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
}
</div>
</section>
<!-- Padding Variants -->
<section class="demo-section">
<h3>Padding Options</h3>
<div class="demo-row">
@for (padding of paddings; track padding) {
<div class="demo-item">
<h4>Padding: {{ padding }}</h4>
<ui-masonry [columns]="3" gap="sm" [padding]="padding">
@for (item of sampleItems.slice(0, 6); track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
}
</div>
</section>
<!-- Alignment Options -->
<section class="demo-section">
<h3>Alignment</h3>
<div class="demo-row">
@for (alignment of alignments; track alignment) {
<div class="demo-item">
<h4>Align: {{ alignment }}</h4>
<ui-masonry [columns]="2" gap="md" padding="md" [alignment]="alignment">
@for (item of sampleItems.slice(0, 4); track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
}
</div>
</section>
<!-- Item Size Variants -->
<section class="demo-section">
<h3>Item Variants</h3>
<div class="demo-row">
<div class="demo-item">
<h4>Mixed Item Sizes</h4>
<ui-masonry [columns]="3" gap="md" padding="md">
<div class="ui-masonry-item ui-masonry-item--compact" style="height: 120px;">
<h5>Compact Item</h5>
<p>Compact padding for dense layouts</p>
</div>
<div class="ui-masonry-item ui-masonry-item--comfortable" style="height: 200px;">
<h5>Comfortable Item</h5>
<p>Standard comfortable padding for most use cases</p>
</div>
<div class="ui-masonry-item ui-masonry-item--spacious" style="height: 180px;">
<h5>Spacious Item</h5>
<p>Extra padding for premium content</p>
</div>
<div class="ui-masonry-item ui-masonry-item--flush" style="height: 160px;">
<div style="padding: 16px; background: linear-gradient(45deg, #667eea 0%, #764ba2 100%); color: white;">
<h5>Full Bleed Item</h5>
<p>No padding for full-width content</p>
</div>
</div>
@for (item of sampleItems.slice(0, 6); track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
</div>
</section>
<!-- Pinterest-style Example -->
<section class="demo-section">
<h3>Pinterest-style Gallery</h3>
<div class="demo-row">
<div class="demo-item full-width">
<ui-masonry columns="auto-fit" gap="lg" padding="lg" minColumnWidth="280px">
@for (item of extendedItems; track item.id) {
<div class="ui-masonry-item ui-masonry-item--comfortable"
[style.height.px]="item.height"
[style.background]="item.background">
<h5 style="color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.5);">{{ item.title }}</h5>
<p style="color: rgba(255,255,255,0.9); text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{{ item.content }}</p>
</div>
}
</ui-masonry>
</div>
</div>
</section>
</div>
`,
styleUrl: './masonry-demo.component.scss'
})
export class MasonryDemoComponent {
columnCounts = [2, 3, 4] as const;
gaps = ['sm', 'md', 'lg'] as const;
paddings = ['none', 'sm', 'md', 'lg'] as const;
alignments = ['start', 'center', 'end'] as const;
sampleItems = [
{ id: 1, title: 'Card 1', content: 'Short content', height: 120 },
{ id: 2, title: 'Card 2', content: 'Medium length content with more details', height: 180 },
{ id: 3, title: 'Card 3', content: 'Brief', height: 100 },
{ id: 4, title: 'Card 4', content: 'Longer content with multiple lines of text to demonstrate variable heights', height: 220 },
{ id: 5, title: 'Card 5', content: 'Standard content', height: 140 },
{ id: 6, title: 'Card 6', content: 'Extended content with even more text to show how masonry handles varying content lengths', height: 260 },
{ id: 7, title: 'Card 7', content: 'Compact', height: 90 },
{ id: 8, title: 'Card 8', content: 'Regular content block', height: 160 },
{ id: 9, title: 'Card 9', content: 'Another piece of content', height: 130 },
{ id: 10, title: 'Card 10', content: 'Different sized content', height: 200 },
{ id: 11, title: 'Card 11', content: 'Small content', height: 110 },
{ id: 12, title: 'Card 12', content: 'Large content with lots of text and information', height: 240 }
];
extendedItems = [
{ id: 1, title: 'Ocean Waves', content: 'Beautiful ocean scenery with crashing waves', height: 180, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ id: 2, title: 'Mountain Vista', content: 'Majestic mountain range at sunset', height: 220, background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ id: 3, title: 'Forest Path', content: 'Winding path through dense forest', height: 160, background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ id: 4, title: 'Desert Dunes', content: 'Golden sand dunes stretching to horizon', height: 200, background: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
{ id: 5, title: 'City Skyline', content: 'Modern city with glowing lights', height: 140, background: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
{ id: 6, title: 'Northern Lights', content: 'Aurora borealis dancing across night sky', height: 250, background: 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)' },
{ id: 7, title: 'Tropical Beach', content: 'Palm trees and crystal clear water', height: 180, background: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' },
{ id: 8, title: 'Snow Peaks', content: 'Snow-capped mountain peaks', height: 190, background: 'linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%)' },
{ id: 9, title: 'Autumn Leaves', content: 'Colorful fall foliage', height: 170, background: 'linear-gradient(135deg, #fad0c4 0%, #fad0c4 1%, #ffd1ff 100%)' },
{ id: 10, title: 'Starry Night', content: 'Countless stars in clear night sky', height: 210, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ id: 11, title: 'Wildflowers', content: 'Field of colorful wildflowers', height: 150, background: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #fecfef 100%)' },
{ id: 12, title: 'Waterfall', content: 'Powerful waterfall cascading down rocks', height: 230, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }
];
}

View File

@@ -0,0 +1,505 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SelectComponent, SelectOption } from '../../../../../ui-essentials/src/lib/components/forms/select/select.component';
@Component({
selector: 'ui-select-demo',
standalone: true,
imports: [
CommonModule,
FormsModule,
SelectComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div style="padding: 2rem;">
<h2>Select Component Showcase</h2>
<!-- Basic Select -->
<section style="margin-bottom: 3rem;">
<h3>Basic Select</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem; max-width: 400px;">
<ui-select
label="Choose a country"
placeholder="Select a country"
[options]="countryOptions"
[(ngModel)]="basicSelect1"
(valueChange)="onSelectionChange('basic-country', $event)"
/>
<ui-select
label="Choose a language"
placeholder="Select a language"
[options]="languageOptions"
[(ngModel)]="basicSelect2"
(valueChange)="onSelectionChange('basic-language', $event)"
/>
<ui-select
label="Choose a framework"
helperText="Select your preferred frontend framework"
[options]="frameworkOptions"
[(ngModel)]="basicSelect3"
(valueChange)="onSelectionChange('basic-framework', $event)"
/>
</div>
</section>
<!-- Size Variants -->
<section style="margin-bottom: 3rem;">
<h3>Size Variants</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem; max-width: 400px;">
<ui-select
label="Small select"
size="small"
placeholder="Choose an option"
[options]="sizeOptions"
[(ngModel)]="sizeSmall"
(valueChange)="onSelectionChange('size-small', $event)"
/>
<ui-select
label="Medium select (default)"
size="medium"
placeholder="Choose an option"
[options]="sizeOptions"
[(ngModel)]="sizeMedium"
(valueChange)="onSelectionChange('size-medium', $event)"
/>
<ui-select
label="Large select"
size="large"
placeholder="Choose an option"
[options]="sizeOptions"
[(ngModel)]="sizeLarge"
(valueChange)="onSelectionChange('size-large', $event)"
/>
</div>
</section>
<!-- Variant Styles -->
<section style="margin-bottom: 3rem;">
<h3>Style Variants</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem; max-width: 400px;">
<ui-select
label="Outlined variant (default)"
variant="outlined"
placeholder="Choose an option"
[options]="variantOptions"
[(ngModel)]="variantOutlined"
(valueChange)="onSelectionChange('variant-outlined', $event)"
/>
<ui-select
label="Filled variant"
variant="filled"
placeholder="Choose an option"
[options]="variantOptions"
[(ngModel)]="variantFilled"
(valueChange)="onSelectionChange('variant-filled', $event)"
/>
</div>
</section>
<!-- States and Validation -->
<section style="margin-bottom: 3rem;">
<h3>States and Validation</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem; max-width: 400px;">
<ui-select
label="Disabled select"
[disabled]="true"
placeholder="This is disabled"
[options]="stateOptions"
[(ngModel)]="stateDisabled"
/>
<ui-select
label="Required field"
[required]="true"
placeholder="This field is required"
[options]="stateOptions"
[(ngModel)]="stateRequired"
(valueChange)="onSelectionChange('state-required', $event)"
/>
<ui-select
label="Field with error"
placeholder="Select an option"
errorText="This field has an error"
[options]="stateOptions"
[(ngModel)]="stateError"
(valueChange)="onSelectionChange('state-error', $event)"
/>
<ui-select
label="Field with helper text"
placeholder="Select an option"
helperText="This is helpful information about this field"
[options]="stateOptions"
[(ngModel)]="stateHelper"
(valueChange)="onSelectionChange('state-helper', $event)"
/>
</div>
</section>
<!-- Full Width -->
<section style="margin-bottom: 3rem;">
<h3>Full Width</h3>
<div style="margin-bottom: 2rem;">
<ui-select
label="Full width select"
placeholder="This select takes full width"
[fullWidth]="true"
[options]="fullWidthOptions"
[(ngModel)]="fullWidthSelect"
(valueChange)="onSelectionChange('full-width', $event)"
/>
</div>
</section>
<!-- Complex Options -->
<section style="margin-bottom: 3rem;">
<h3>Complex Options with Disabled Items</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem; max-width: 400px;">
<ui-select
label="User roles"
placeholder="Select a role"
helperText="Some roles may be disabled based on permissions"
[options]="roleOptions"
[(ngModel)]="complexSelect1"
(valueChange)="onSelectionChange('complex-roles', $event)"
/>
<ui-select
label="Priority levels"
placeholder="Select priority"
[options]="priorityOptions"
[(ngModel)]="complexSelect2"
(valueChange)="onSelectionChange('complex-priority', $event)"
/>
</div>
</section>
<!-- Interactive Controls -->
<section style="margin-bottom: 3rem;">
<h3>Interactive Controls</h3>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<button
style="padding: 0.5rem 1rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;"
(click)="setDefaultValues()"
>
Set Default Values
</button>
<button
style="padding: 0.5rem 1rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;"
(click)="clearAllSelections()"
>
Clear All
</button>
<button
style="padding: 0.5rem 1rem; background: #6f42c1; color: white; border: none; border-radius: 4px; cursor: pointer;"
(click)="toggleDisabled()"
>
{{ globalDisabled() ? 'Enable All' : 'Disable All' }}
</button>
</div>
@if (lastSelection()) {
<div style="margin-top: 1rem; padding: 1rem; background: #e3f2fd; border-radius: 4px;">
<strong>Last selection:</strong> {{ lastSelection() }}
</div>
}
</section>
<!-- Code Examples -->
<section style="margin-bottom: 3rem;">
<h3>Code Examples</h3>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
<h4>Basic Usage:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-select
label="Choose a country"
placeholder="Select a country"
[options]="countryOptions"
[(ngModel)]="selectedCountry"
(valueChange)="onCountryChange($event)"&gt;
&lt;/ui-select&gt;</code></pre>
<h4>With Helper Text:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-select
label="Framework"
placeholder="Select framework"
helperText="Choose your preferred frontend framework"
size="large"
variant="filled"
[options]="frameworks"
[(ngModel)]="selectedFramework"&gt;
&lt;/ui-select&gt;</code></pre>
<h4>Required with Error State:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-select
label="Required field"
[required]="true"
errorText="Please select an option"
[options]="options"
[(ngModel)]="selectedValue"&gt;
&lt;/ui-select&gt;</code></pre>
<h4>Options Format:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>const options: SelectOption[] = [
{{ '{' }} value: 'option1', label: 'Option 1' {{ '}' }},
{{ '{' }} value: 'option2', label: 'Option 2' {{ '}' }},
{{ '{' }} value: 'option3', label: 'Option 3 (Disabled)', disabled: true {{ '}' }}
];</code></pre>
</div>
</section>
<!-- Current Values -->
<section style="margin-bottom: 3rem;">
<h3>Current Values</h3>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; max-height: 400px; overflow-y: auto;">
<pre>{{ getCurrentValues() }}</pre>
</div>
</section>
</div>
`,
styles: [`
h2 {
color: hsl(279, 14%, 11%);
font-size: 2rem;
margin-bottom: 2rem;
border-bottom: 2px solid hsl(258, 100%, 47%);
padding-bottom: 0.5rem;
}
h3 {
color: hsl(279, 14%, 25%);
font-size: 1.5rem;
margin-bottom: 1rem;
}
h4 {
color: hsl(279, 14%, 35%);
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
pre {
margin: 0;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.4;
}
code {
color: hsl(279, 14%, 15%);
}
section {
border: 1px solid #e9ecef;
padding: 1.5rem;
border-radius: 8px;
background: #ffffff;
}
button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
`]
})
export class SelectDemoComponent {
// Basic selects
basicSelect1 = signal<string>('');
basicSelect2 = signal<string>('en');
basicSelect3 = signal<string>('');
// Size variants
sizeSmall = signal<string>('');
sizeMedium = signal<string>('medium');
sizeLarge = signal<string>('');
// Style variants
variantOutlined = signal<string>('');
variantFilled = signal<string>('filled');
// States
stateDisabled = signal<string>('disabled');
stateRequired = signal<string>('');
stateError = signal<string>('');
stateHelper = signal<string>('');
// Full width
fullWidthSelect = signal<string>('');
// Complex selects
complexSelect1 = signal<string>('');
complexSelect2 = signal<string>('medium');
// Control states
globalDisabled = signal<boolean>(false);
lastSelection = signal<string>('');
// Option arrays
countryOptions: SelectOption[] = [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'de', label: 'Germany' },
{ value: 'fr', label: 'France' },
{ value: 'jp', label: 'Japan' },
{ value: 'au', label: 'Australia' }
];
languageOptions: SelectOption[] = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'zh', label: 'Chinese' }
];
frameworkOptions: SelectOption[] = [
{ value: 'angular', label: 'Angular' },
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue.js' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'SolidJS' }
];
sizeOptions: SelectOption[] = [
{ value: 'small', label: 'Small Size' },
{ value: 'medium', label: 'Medium Size' },
{ value: 'large', label: 'Large Size' }
];
variantOptions: SelectOption[] = [
{ value: 'outlined', label: 'Outlined Style' },
{ value: 'filled', label: 'Filled Style' }
];
stateOptions: SelectOption[] = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'pending', label: 'Pending' }
];
fullWidthOptions: SelectOption[] = [
{ value: 'option1', label: 'This is a full width select option' },
{ value: 'option2', label: 'Another full width option with longer text' },
{ value: 'option3', label: 'Third option demonstrating full width behavior' }
];
roleOptions: SelectOption[] = [
{ value: 'user', label: 'User' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'admin', label: 'Administrator' },
{ value: 'super-admin', label: 'Super Administrator', disabled: true },
{ value: 'guest', label: 'Guest', disabled: true }
];
priorityOptions: SelectOption[] = [
{ value: 'low', label: 'Low Priority' },
{ value: 'medium', label: 'Medium Priority' },
{ value: 'high', label: 'High Priority' },
{ value: 'urgent', label: 'Urgent' },
{ value: 'critical', label: 'Critical', disabled: true }
];
onSelectionChange(selectId: string, value: any): void {
const selectedOption = this.findOptionByValue(value);
const displayValue = selectedOption ? selectedOption.label : value;
this.lastSelection.set(`${selectId}: ${displayValue} (${value})`);
console.log(`Select ${selectId} changed:`, value);
}
private findOptionByValue(value: any): SelectOption | undefined {
const allOptions = [
...this.countryOptions,
...this.languageOptions,
...this.frameworkOptions,
...this.sizeOptions,
...this.variantOptions,
...this.stateOptions,
...this.fullWidthOptions,
...this.roleOptions,
...this.priorityOptions
];
return allOptions.find(option => option.value === value);
}
setDefaultValues(): void {
this.basicSelect1.set('us');
this.basicSelect2.set('en');
this.basicSelect3.set('angular');
this.sizeSmall.set('small');
this.sizeMedium.set('medium');
this.sizeLarge.set('large');
this.variantOutlined.set('outlined');
this.variantFilled.set('filled');
this.stateRequired.set('active');
this.stateError.set('pending');
this.stateHelper.set('inactive');
this.fullWidthSelect.set('option2');
this.complexSelect1.set('admin');
this.complexSelect2.set('high');
this.lastSelection.set('All selects set to default values');
}
clearAllSelections(): void {
this.basicSelect1.set('');
this.basicSelect2.set('');
this.basicSelect3.set('');
this.sizeSmall.set('');
this.sizeMedium.set('');
this.sizeLarge.set('');
this.variantOutlined.set('');
this.variantFilled.set('');
this.stateRequired.set('');
this.stateError.set('');
this.stateHelper.set('');
this.fullWidthSelect.set('');
this.complexSelect1.set('');
this.complexSelect2.set('');
this.lastSelection.set('All selections cleared');
}
toggleDisabled(): void {
this.globalDisabled.update(disabled => !disabled);
this.lastSelection.set(`Global disabled: ${this.globalDisabled() ? 'enabled' : 'disabled'}`);
}
getCurrentValues(): string {
const values = {
basic: {
country: this.basicSelect1(),
language: this.basicSelect2(),
framework: this.basicSelect3()
},
sizes: {
small: this.sizeSmall(),
medium: this.sizeMedium(),
large: this.sizeLarge()
},
variants: {
outlined: this.variantOutlined(),
filled: this.variantFilled()
},
states: {
disabled: this.stateDisabled(),
required: this.stateRequired(),
error: this.stateError(),
helper: this.stateHelper()
},
layout: {
fullWidth: this.fullWidthSelect()
},
complex: {
roles: this.complexSelect1(),
priority: this.complexSelect2()
},
controls: {
globalDisabled: this.globalDisabled(),
lastSelection: this.lastSelection()
}
};
return JSON.stringify(values, null, 2);
}
}

View File

@@ -0,0 +1,157 @@
@use '../../../../../ui-design-system/src/styles/semantic' as *;
.demo-container {
padding: $semantic-spacing-component-lg;
.demo-section {
margin-bottom: $semantic-spacing-layout-section-lg;
h3 {
font-family: map-get($semantic-typography-heading-h3, font-family);
font-size: map-get($semantic-typography-heading-h3, font-size);
font-weight: map-get($semantic-typography-heading-h3, font-weight);
line-height: map-get($semantic-typography-heading-h3, line-height);
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-md;
}
.demo-split-container {
height: 400px;
margin-bottom: $semantic-spacing-component-lg;
border: $semantic-border-width-1 solid $semantic-color-border-secondary;
border-radius: $semantic-border-radius-md;
}
.demo-panel-content {
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-primary;
height: 100%;
h4 {
font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-sm;
}
p {
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-content-paragraph;
}
&--primary {
background: $semantic-color-surface-primary;
}
&--secondary {
background: $semantic-color-surface-secondary;
}
&--elevated {
background: $semantic-color-surface-elevated;
box-shadow: $semantic-shadow-elevation-1;
}
}
.demo-controls {
display: flex;
gap: $semantic-spacing-component-sm;
margin-bottom: $semantic-spacing-component-md;
flex-wrap: wrap;
button {
padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x;
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border: none;
border-radius: $semantic-border-button-radius;
cursor: pointer;
font-family: map-get($semantic-typography-button-medium, font-family);
font-size: map-get($semantic-typography-button-medium, font-size);
font-weight: map-get($semantic-typography-button-medium, font-weight);
line-height: map-get($semantic-typography-button-medium, line-height);
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:hover {
background: $semantic-color-primary;
box-shadow: $semantic-shadow-button-hover;
opacity: $semantic-opacity-hover;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&--secondary {
background: $semantic-color-secondary;
color: $semantic-color-on-secondary;
}
&--success {
background: $semantic-color-success;
color: $semantic-color-on-success;
}
&--danger {
background: $semantic-color-danger;
color: $semantic-color-on-danger;
}
}
}
.demo-info {
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-container;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
border-radius: $semantic-border-radius-sm;
margin-top: $semantic-spacing-component-md;
p {
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-content-line-tight;
&:last-child {
margin-bottom: 0;
}
}
strong {
color: $semantic-color-text-primary;
font-weight: $semantic-typography-font-weight-semibold;
}
}
}
}
@media (max-width: $semantic-breakpoint-md - 1) {
.demo-container {
padding: $semantic-spacing-component-md;
.demo-section .demo-split-container {
height: 300px;
}
.demo-section .demo-controls {
gap: $semantic-spacing-component-xs;
button {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
font-family: map-get($semantic-typography-button-small, font-family);
font-size: map-get($semantic-typography-button-small, font-size);
font-weight: map-get($semantic-typography-button-small, font-weight);
line-height: map-get($semantic-typography-button-small, line-height);
}
}
}
}

View File

@@ -0,0 +1,315 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SplitViewComponent, PanelConfig, ResizeEvent } from 'ui-essentials';
@Component({
selector: 'ui-split-view-demo',
standalone: true,
imports: [CommonModule, SplitViewComponent],
template: `
<div class="demo-container">
<h2>Split View / Resizable Panels Demo</h2>
<!-- Basic Horizontal Split -->
<section class="demo-section">
<h3>Basic Horizontal Split</h3>
<div class="demo-split-container">
<ui-split-view
[panels]="basicHorizontalPanels"
direction="horizontal"
size="md"
(panelResized)="onPanelResized($event)">
<div slot="left-panel" class="demo-panel-content demo-panel-content--primary">
<h4>Left Panel</h4>
<p>This is the left panel content. Drag the divider to resize!</p>
<p>You can add any content here including forms, lists, or other components.</p>
</div>
<div slot="right-panel" class="demo-panel-content demo-panel-content--secondary">
<h4>Right Panel</h4>
<p>This is the right panel content.</p>
<p>Panels maintain their content during resize operations.</p>
</div>
</ui-split-view>
</div>
<div class="demo-info">
<p><strong>Usage:</strong> Drag the vertical divider to resize panels horizontally.</p>
</div>
</section>
<!-- Basic Vertical Split -->
<section class="demo-section">
<h3>Basic Vertical Split</h3>
<div class="demo-split-container">
<ui-split-view
[panels]="basicVerticalPanels"
direction="vertical"
size="md"
(panelResized)="onPanelResized($event)">
<div slot="top-panel" class="demo-panel-content demo-panel-content--elevated">
<h4>Top Panel</h4>
<p>This is the top panel in a vertical split layout.</p>
</div>
<div slot="bottom-panel" class="demo-panel-content demo-panel-content--primary">
<h4>Bottom Panel</h4>
<p>This is the bottom panel. The divider can be dragged vertically.</p>
</div>
</ui-split-view>
</div>
<div class="demo-info">
<p><strong>Usage:</strong> Drag the horizontal divider to resize panels vertically.</p>
</div>
</section>
<!-- Three Panel Layout -->
<section class="demo-section">
<h3>Three Panel Layout</h3>
<div class="demo-split-container">
<ui-split-view
[panels]="threePanelLayout"
direction="horizontal"
size="lg"
(panelResized)="onPanelResized($event)">
<div slot="sidebar" class="demo-panel-content demo-panel-content--secondary">
<h4>Sidebar</h4>
<p>Navigation or menu content</p>
</div>
<div slot="main-content" class="demo-panel-content demo-panel-content--primary">
<h4>Main Content</h4>
<p>Primary content area with the most space.</p>
<p>This panel takes up the majority of the available space.</p>
</div>
<div slot="details-panel" class="demo-panel-content demo-panel-content--elevated">
<h4>Details Panel</h4>
<p>Additional information or tools</p>
</div>
</ui-split-view>
</div>
<div class="demo-controls">
<button (click)="collapsePanel('sidebar')">Collapse Sidebar</button>
<button (click)="expandPanel('sidebar')" class="button--secondary">Expand Sidebar</button>
<button (click)="collapsePanel('details-panel')" class="button--success">Collapse Details</button>
<button (click)="expandPanel('details-panel')" class="button--success">Expand Details</button>
<button (click)="resetThreePanelSizes()" class="button--danger">Reset Sizes</button>
</div>
<div class="demo-info">
<p><strong>Features:</strong> Multiple panels with programmatic collapse/expand controls.</p>
</div>
</section>
<!-- Size Variants -->
<section class="demo-section">
<h3>Size Variants</h3>
<h4>Small Size</h4>
<div class="demo-split-container" style="height: 200px;">
<ui-split-view
[panels]="sizeVariantPanels"
direction="horizontal"
size="sm">
<div slot="size-left" class="demo-panel-content demo-panel-content--primary">
<h4>Small Split View</h4>
<p>Compact layout with reduced spacing.</p>
</div>
<div slot="size-right" class="demo-panel-content demo-panel-content--secondary">
<h4>Right Panel</h4>
<p>Smaller padding and gaps.</p>
</div>
</ui-split-view>
</div>
<h4>Large Size</h4>
<div class="demo-split-container" style="height: 500px;">
<ui-split-view
[panels]="sizeVariantPanels2"
direction="horizontal"
size="lg">
<div slot="size-left-lg" class="demo-panel-content demo-panel-content--primary">
<h4>Large Split View</h4>
<p>Spacious layout with generous spacing and padding.</p>
<p>Perfect for complex layouts requiring more visual breathing room.</p>
</div>
<div slot="size-right-lg" class="demo-panel-content demo-panel-content--elevated">
<h4>Right Panel</h4>
<p>Enhanced spacing creates a more luxurious feel.</p>
</div>
</ui-split-view>
</div>
<div class="demo-info">
<p><strong>Sizes:</strong> sm (compact), md (default), lg (spacious) - affects padding and spacing.</p>
</div>
</section>
<!-- Panel Constraints -->
<section class="demo-section">
<h3>Panel Size Constraints</h3>
<div class="demo-split-container">
<ui-split-view
[panels]="constrainedPanels"
direction="horizontal"
size="md"
(panelResized)="onPanelResized($event)">
<div slot="constrained-left" class="demo-panel-content demo-panel-content--primary">
<h4>Constrained Panel</h4>
<p>Min: 150px, Max: 400px</p>
<p>Try resizing - this panel has size constraints!</p>
</div>
<div slot="constrained-right" class="demo-panel-content demo-panel-content--secondary">
<h4>Flexible Panel</h4>
<p>No constraints - adapts to available space.</p>
</div>
</ui-split-view>
</div>
<div class="demo-info">
<p><strong>Constraints:</strong> Left panel has min-width of 150px and max-width of 400px.</p>
</div>
</section>
<!-- Disabled State -->
<section class="demo-section">
<h3>Disabled State</h3>
<div class="demo-split-container">
<ui-split-view
[panels]="basicHorizontalPanels"
direction="horizontal"
size="md"
[disabled]="true">
<div slot="left-panel" class="demo-panel-content demo-panel-content--primary">
<h4>Disabled Split View</h4>
<p>Resizing is disabled - dividers cannot be dragged.</p>
</div>
<div slot="right-panel" class="demo-panel-content demo-panel-content--secondary">
<h4>Static Panel</h4>
<p>All interaction is disabled when the component is in disabled state.</p>
</div>
</ui-split-view>
</div>
<div class="demo-info">
<p><strong>Disabled:</strong> All resize functionality is disabled.</p>
</div>
</section>
<!-- Nested Split Views -->
<section class="demo-section">
<h3>Nested Split Views</h3>
<div class="demo-split-container" style="height: 500px;">
<ui-split-view
[panels]="outerNestedPanels"
direction="horizontal"
size="md">
<div slot="outer-left" class="demo-panel-content demo-panel-content--secondary">
<h4>Left Panel</h4>
<p>This is the outer left panel.</p>
</div>
<div slot="outer-right" style="padding: 0;">
<ui-split-view
[panels]="innerNestedPanels"
direction="vertical"
size="md">
<div slot="inner-top" class="demo-panel-content demo-panel-content--primary">
<h4>Nested Top</h4>
<p>This is a nested split view inside the right panel.</p>
</div>
<div slot="inner-bottom" class="demo-panel-content demo-panel-content--elevated">
<h4>Nested Bottom</h4>
<p>Fully functional nested resizable panels.</p>
</div>
</ui-split-view>
</div>
</ui-split-view>
</div>
<div class="demo-info">
<p><strong>Nesting:</strong> Split views can be nested to create complex layouts.</p>
</div>
</section>
<!-- Event Information -->
<section class="demo-section">
<h3>Resize Events</h3>
<div class="demo-info">
<p><strong>Last Resize Event:</strong> {{ lastResizeEvent || 'None' }}</p>
<p><strong>Resize Count:</strong> {{ resizeCount }}</p>
</div>
</section>
</div>
`,
styleUrl: './split-view-demo.component.scss'
})
export class SplitViewDemoComponent {
resizeCount = 0;
lastResizeEvent = '';
// Basic horizontal panels
basicHorizontalPanels: PanelConfig[] = [
{ id: 'left-panel', size: '40%' },
{ id: 'right-panel', size: '60%' }
];
// Basic vertical panels
basicVerticalPanels: PanelConfig[] = [
{ id: 'top-panel', size: '30%' },
{ id: 'bottom-panel', size: '70%' }
];
// Three panel layout
threePanelLayout: PanelConfig[] = [
{ id: 'sidebar', size: '250px', minSize: 200, maxSize: 400, variant: 'secondary' },
{ id: 'main-content', size: '1fr', variant: 'primary' },
{ id: 'details-panel', size: '300px', minSize: 250, maxSize: 500, variant: 'elevated' }
];
// Size variant panels
sizeVariantPanels: PanelConfig[] = [
{ id: 'size-left', size: '50%' },
{ id: 'size-right', size: '50%' }
];
sizeVariantPanels2: PanelConfig[] = [
{ id: 'size-left-lg', size: '40%' },
{ id: 'size-right-lg', size: '60%' }
];
// Constrained panels
constrainedPanels: PanelConfig[] = [
{ id: 'constrained-left', size: '300px', minSize: 150, maxSize: 400 },
{ id: 'constrained-right', size: '1fr' }
];
// Nested panels
outerNestedPanels: PanelConfig[] = [
{ id: 'outer-left', size: '30%' },
{ id: 'outer-right', size: '70%' }
];
innerNestedPanels: PanelConfig[] = [
{ id: 'inner-top', size: '40%' },
{ id: 'inner-bottom', size: '60%' }
];
onPanelResized(event: ResizeEvent): void {
this.resizeCount++;
this.lastResizeEvent = `Panel ${event.panelId} resized to ${Math.round(event.newSize)}px (${event.direction})`;
console.log('Panel resized:', event);
}
collapsePanel(panelId: string): void {
const panel = this.threePanelLayout.find(p => p.id === panelId);
if (panel) {
panel.collapsed = true;
}
}
expandPanel(panelId: string): void {
const panel = this.threePanelLayout.find(p => p.id === panelId);
if (panel) {
panel.collapsed = false;
}
}
resetThreePanelSizes(): void {
this.threePanelLayout = [
{ id: 'sidebar', size: '250px', minSize: 200, maxSize: 400, variant: 'secondary' },
{ id: 'main-content', size: '1fr', variant: 'primary' },
{ id: 'details-panel', size: '300px', minSize: 250, maxSize: 500, variant: 'elevated' }
];
}
}

View File

@@ -0,0 +1,189 @@
.demo-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
margin-bottom: 3rem;
h3 {
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0;
}
h4 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
}
.demo-scroll-container {
height: 400px;
overflow-y: auto;
border: 2px solid #f0f0f0;
border-radius: 8px;
position: relative;
background: linear-gradient(to bottom, #fafafa, #f0f0f0);
}
.demo-content-spacer {
padding: 2rem;
min-height: 600px;
p {
margin-bottom: 2rem;
text-align: center;
font-weight: 500;
}
}
.demo-filler {
height: 100px;
margin: 1rem 0;
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: #1976d2;
}
.demo-content {
padding: 1rem 2rem;
background: #fff;
border-radius: 4px;
font-weight: 500;
text-align: center;
color: #333;
}
.demo-content-small {
padding: 0.75rem 1.5rem;
background: #fff;
border-radius: 4px;
font-weight: 500;
text-align: center;
color: #333;
font-size: 0.9rem;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
margin-top: 1rem;
}
.demo-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.demo-variant-example {
padding: 1.5rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fafafa;
position: relative;
min-height: 120px;
h4 {
position: absolute;
top: 0.5rem;
left: 1rem;
margin: 0;
font-size: 0.9rem;
color: #666;
}
}
.demo-effect-example {
padding: 1.5rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
position: relative;
min-height: 120px;
h4 {
position: absolute;
top: 0.5rem;
left: 1rem;
margin: 0;
font-size: 0.9rem;
color: #666;
}
}
.demo-controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
label {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-weight: 500;
select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 120px;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
}
}
}
.demo-preview {
height: 300px;
border: 2px dashed #ddd;
border-radius: 8px;
position: relative;
overflow: auto;
background: linear-gradient(45deg, #f9f9f9 25%, transparent 25%),
linear-gradient(-45deg, #f9f9f9 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f9f9f9 75%),
linear-gradient(-45deg, transparent 75%, #f9f9f9 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
@media (max-width: 768px) {
.demo-container {
padding: 1rem;
}
.demo-grid {
grid-template-columns: 1fr;
}
.demo-row {
flex-direction: column;
}
.demo-controls {
flex-direction: column;
label {
flex-direction: row;
align-items: center;
select {
min-width: auto;
flex: 1;
}
}
}
}

View File

@@ -0,0 +1,229 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { StickyLayoutComponent } from 'ui-essentials';
@Component({
selector: 'ui-sticky-layout-demo',
standalone: true,
imports: [CommonModule, FormsModule, StickyLayoutComponent],
template: `
<div class="demo-container">
<h2>Sticky Layout Demo</h2>
<p>Scroll down to see the sticky positioning in action.</p>
<!-- Position Variants -->
<section class="demo-section">
<h3>Position Variants</h3>
<div class="demo-scroll-container">
<ui-sticky-layout position="top" variant="header" [shadow]="true">
<div class="demo-content">Sticky Top Header</div>
</ui-sticky-layout>
<div class="demo-content-spacer">
<p>Scroll down to see sticky positioning...</p>
<div class="demo-filler">Content area 1</div>
<div class="demo-filler">Content area 2</div>
<div class="demo-filler">Content area 3</div>
<div class="demo-filler">Content area 4</div>
<div class="demo-filler">Content area 5</div>
</div>
<ui-sticky-layout position="bottom" variant="footer" [shadow]="true">
<div class="demo-content">Sticky Bottom Footer</div>
</ui-sticky-layout>
</div>
</section>
<!-- Variant Styles -->
<section class="demo-section">
<h3>Variant Styles</h3>
<div class="demo-grid">
@for (variant of variants; track variant.name) {
<div class="demo-variant-example">
<h4>{{ variant.name }}</h4>
<ui-sticky-layout
[position]="variant.position"
[variant]="variant.variant"
[shadow]="variant.shadow"
[background]="variant.background">
<div class="demo-content-small">
{{ variant.name }} Sticky
</div>
</ui-sticky-layout>
</div>
}
</div>
</section>
<!-- Offset Examples -->
<section class="demo-section">
<h3>Offset Variants</h3>
<div class="demo-row">
@for (offset of offsets; track offset) {
<ui-sticky-layout
position="top"
[offset]="offset"
variant="toolbar"
[shadow]="true">
<div class="demo-content-small">
Offset: {{ offset }}
</div>
</ui-sticky-layout>
}
</div>
</section>
<!-- Background and Effects -->
<section class="demo-section">
<h3>Background & Effects</h3>
<div class="demo-grid">
<div class="demo-effect-example">
<h4>Blur Effect</h4>
<ui-sticky-layout
position="top"
background="surface"
[blur]="true"
[shadow]="true">
<div class="demo-content-small">Blurred Background</div>
</ui-sticky-layout>
</div>
<div class="demo-effect-example">
<h4>Backdrop</h4>
<ui-sticky-layout
position="top"
background="backdrop"
[border]="true">
<div class="demo-content-small">Backdrop Filter</div>
</ui-sticky-layout>
</div>
<div class="demo-effect-example">
<h4>Floating</h4>
<ui-sticky-layout
position="top"
variant="floating"
offset="md">
<div class="demo-content-small">Floating Style</div>
</ui-sticky-layout>
</div>
<div class="demo-effect-example">
<h4>Toolbar</h4>
<ui-sticky-layout
position="top"
variant="toolbar"
[shadow]="true"
[border]="true">
<div class="demo-content-small">Toolbar Style</div>
</ui-sticky-layout>
</div>
</div>
</section>
<!-- Size Variants -->
<section class="demo-section">
<h3>Size Variants</h3>
<div class="demo-row">
@for (size of sizes; track size) {
<ui-sticky-layout
position="top"
[size]="size"
variant="header"
[shadow]="true">
<div class="demo-content-small">
Size: {{ size }}
</div>
</ui-sticky-layout>
}
</div>
</section>
<!-- Interactive Example -->
<section class="demo-section">
<h3>Interactive Example</h3>
<div class="demo-controls">
<label>
Position:
<select [(ngModel)]="selectedPosition" (change)="updateConfig()">
@for (pos of positions; track pos) {
<option [value]="pos">{{ pos }}</option>
}
</select>
</label>
<label>
Variant:
<select [(ngModel)]="selectedVariant" (change)="updateConfig()">
@for (variant of variantOptions; track variant) {
<option [value]="variant">{{ variant }}</option>
}
</select>
</label>
<label>
<input type="checkbox" [(ngModel)]="enableShadow" (change)="updateConfig()">
Shadow
</label>
<label>
<input type="checkbox" [(ngModel)]="enableBorder" (change)="updateConfig()">
Border
</label>
<label>
<input type="checkbox" [(ngModel)]="enableBlur" (change)="updateConfig()">
Blur
</label>
</div>
<div class="demo-preview">
<ui-sticky-layout
[position]="selectedPosition"
[variant]="selectedVariant"
[shadow]="enableShadow"
[border]="enableBorder"
[blur]="enableBlur"
background="surface">
<div class="demo-content">
Dynamic Sticky Layout
</div>
</ui-sticky-layout>
</div>
</section>
</div>
`,
styleUrl: './sticky-layout-demo.component.scss'
})
export class StickyLayoutDemoComponent {
positions = ['top', 'bottom', 'left', 'right'] as const;
offsets = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
sizes = ['auto', 'full', 'content'] as const;
variantOptions = ['default', 'header', 'sidebar', 'footer', 'floating', 'toolbar'] as const;
variants = [
{ name: 'Header', variant: 'header' as const, position: 'top' as const, shadow: true, background: 'surface' as const },
{ name: 'Sidebar', variant: 'sidebar' as const, position: 'left' as const, shadow: false, background: 'surface-secondary' as const },
{ name: 'Footer', variant: 'footer' as const, position: 'bottom' as const, shadow: true, background: 'surface' as const },
{ name: 'Floating', variant: 'floating' as const, position: 'top' as const, shadow: true, background: 'surface-elevated' as const },
{ name: 'Toolbar', variant: 'toolbar' as const, position: 'top' as const, shadow: true, background: 'surface-elevated' as const }
];
// Interactive controls
selectedPosition: typeof this.positions[number] = 'top';
selectedVariant: typeof this.variantOptions[number] = 'header';
enableShadow = true;
enableBorder = false;
enableBlur = false;
updateConfig(): void {
console.log('Sticky layout config updated:', {
position: this.selectedPosition,
variant: this.selectedVariant,
shadow: this.enableShadow,
border: this.enableBorder,
blur: this.enableBlur
});
}
}

View File

@@ -0,0 +1,591 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TextareaComponent } from '../../../../../ui-essentials/src/lib/components/forms/input/textarea.component';
import { faComment, faEdit, faEnvelope } from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'ui-textarea-demo',
standalone: true,
imports: [
CommonModule,
FormsModule,
TextareaComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div style="padding: 2rem;">
<h2>Textarea Component Showcase</h2>
<!-- Basic Textarea -->
<section style="margin-bottom: 3rem;">
<h3>Basic Textarea</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="Basic textarea"
placeholder="Enter your message here..."
[(ngModel)]="basicTextarea1"
(textareaChange)="onTextareaChange('basic-1', $event)"
/>
<ui-textarea
label="Textarea with helper text"
placeholder="Share your thoughts..."
helperText="Your feedback is important to us"
[(ngModel)]="basicTextarea2"
(textareaChange)="onTextareaChange('basic-2', $event)"
/>
<ui-textarea
label="Pre-filled textarea"
placeholder="Enter description..."
[(ngModel)]="basicTextarea3"
(textareaChange)="onTextareaChange('basic-3', $event)"
/>
</div>
</section>
<!-- Size Variants -->
<section style="margin-bottom: 3rem;">
<h3>Size Variants</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="Small textarea"
size="sm"
placeholder="Small size textarea..."
[(ngModel)]="sizeSmall"
(textareaChange)="onTextareaChange('size-small', $event)"
/>
<ui-textarea
label="Medium textarea (default)"
size="md"
placeholder="Medium size textarea..."
[(ngModel)]="sizeMedium"
(textareaChange)="onTextareaChange('size-medium', $event)"
/>
<ui-textarea
label="Large textarea"
size="lg"
placeholder="Large size textarea..."
[(ngModel)]="sizeLarge"
(textareaChange)="onTextareaChange('size-large', $event)"
/>
</div>
</section>
<!-- Variant Styles -->
<section style="margin-bottom: 3rem;">
<h3>Style Variants</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="Outlined variant (default)"
variant="outlined"
placeholder="Outlined style textarea..."
[(ngModel)]="variantOutlined"
(textareaChange)="onTextareaChange('variant-outlined', $event)"
/>
<ui-textarea
label="Filled variant"
variant="filled"
placeholder="Filled style textarea..."
[(ngModel)]="variantFilled"
(textareaChange)="onTextareaChange('variant-filled', $event)"
/>
<ui-textarea
label="Underlined variant"
variant="underlined"
placeholder="Underlined style textarea..."
[(ngModel)]="variantUnderlined"
(textareaChange)="onTextareaChange('variant-underlined', $event)"
/>
</div>
</section>
<!-- States -->
<section style="margin-bottom: 3rem;">
<h3>States and Validation</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="Default state"
state="default"
placeholder="Normal textarea..."
[(ngModel)]="stateDefault"
(textareaChange)="onTextareaChange('state-default', $event)"
/>
<ui-textarea
label="Success state"
state="success"
placeholder="Successful input..."
helperText="This looks good!"
[(ngModel)]="stateSuccess"
(textareaChange)="onTextareaChange('state-success', $event)"
/>
<ui-textarea
label="Warning state"
state="warning"
placeholder="Input with warning..."
helperText="Please double-check this content"
[(ngModel)]="stateWarning"
(textareaChange)="onTextareaChange('state-warning', $event)"
/>
<ui-textarea
label="Error state"
state="error"
placeholder="Input with error..."
errorMessage="This field contains an error"
[(ngModel)]="stateError"
(textareaChange)="onTextareaChange('state-error', $event)"
/>
</div>
</section>
<!-- Interactive States -->
<section style="margin-bottom: 3rem;">
<h3>Interactive States</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="Disabled textarea"
[disabled]="true"
placeholder="This textarea is disabled"
[(ngModel)]="stateDisabled"
/>
<ui-textarea
label="Readonly textarea"
[readonly]="true"
placeholder="This textarea is readonly"
[(ngModel)]="stateReadonly"
/>
<ui-textarea
label="Required field"
[required]="true"
placeholder="This field is required"
[(ngModel)]="stateRequired"
(textareaChange)="onTextareaChange('state-required', $event)"
/>
<ui-textarea
label="Loading state"
[loading]="true"
placeholder="This textarea is in loading state"
[(ngModel)]="stateLoading"
(textareaChange)="onTextareaChange('state-loading', $event)"
/>
</div>
</section>
<!-- Character Limits and Counters -->
<section style="margin-bottom: 3rem;">
<h3>Character Limits and Counters</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="Textarea with character limit"
placeholder="You have 100 characters max..."
[maxLength]="100"
[showCharacterCount]="true"
helperText="Please keep your message concise"
[(ngModel)]="charLimit1"
(textareaChange)="onTextareaChange('char-limit-1', $event)"
/>
<ui-textarea
label="Tweet-style limit"
placeholder="What's happening?"
[maxLength]="280"
[showCharacterCount]="true"
[(ngModel)]="charLimit2"
(textareaChange)="onTextareaChange('char-limit-2', $event)"
/>
<ui-textarea
label="Long form content"
placeholder="Write your article here..."
[maxLength]="1000"
[showCharacterCount]="true"
[rows]="8"
[(ngModel)]="charLimit3"
(textareaChange)="onTextareaChange('char-limit-3', $event)"
/>
</div>
</section>
<!-- Row and Resize Options -->
<section style="margin-bottom: 3rem;">
<h3>Rows and Resize Options</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="2 rows (compact)"
placeholder="Short input area..."
[rows]="2"
resize="none"
[(ngModel)]="rowsCompact"
(textareaChange)="onTextareaChange('rows-compact', $event)"
/>
<ui-textarea
label="6 rows (expanded)"
placeholder="Larger input area..."
[rows]="6"
resize="vertical"
[(ngModel)]="rowsExpanded"
(textareaChange)="onTextareaChange('rows-expanded', $event)"
/>
<ui-textarea
label="Auto-resize textarea"
placeholder="This textarea will grow as you type..."
[autoResize]="true"
[rows]="3"
[(ngModel)]="autoResizeTextarea"
(textareaChange)="onTextareaChange('auto-resize', $event)"
/>
<ui-textarea
label="Horizontal resize"
placeholder="You can resize this horizontally..."
resize="horizontal"
[(ngModel)]="horizontalResize"
(textareaChange)="onTextareaChange('horizontal-resize', $event)"
/>
<ui-textarea
label="Both directions resize"
placeholder="Resize in any direction..."
resize="both"
[(ngModel)]="bothResize"
(textareaChange)="onTextareaChange('both-resize', $event)"
/>
</div>
</section>
<!-- Icons and Clearable -->
<section style="margin-bottom: 3rem;">
<h3>Icons and Clearable Features</h3>
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;">
<ui-textarea
label="With prefix icon"
placeholder="Type your comment..."
[prefixIcon]="faComment"
[(ngModel)]="iconPrefix"
(textareaChange)="onTextareaChange('icon-prefix', $event)"
/>
<ui-textarea
label="With suffix icon"
placeholder="Write your email content..."
[suffixIcon]="faEnvelope"
[(ngModel)]="iconSuffix"
(textareaChange)="onTextareaChange('icon-suffix', $event)"
/>
<ui-textarea
label="Clearable textarea"
placeholder="You can clear this easily..."
[clearable]="true"
[prefixIcon]="faEdit"
[(ngModel)]="clearableTextarea"
(textareaChange)="onTextareaChange('clearable', $event)"
(clear)="onClear('clearable')"
/>
</div>
</section>
<!-- Interactive Controls -->
<section style="margin-bottom: 3rem;">
<h3>Interactive Controls</h3>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<button
style="padding: 0.5rem 1rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;"
(click)="fillSampleText()"
>
Fill Sample Text
</button>
<button
style="padding: 0.5rem 1rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;"
(click)="clearAllTextareas()"
>
Clear All
</button>
<button
style="padding: 0.5rem 1rem; background: #6f42c1; color: white; border: none; border-radius: 4px; cursor: pointer;"
(click)="toggleDisabled()"
>
{{ globalDisabled() ? 'Enable All' : 'Disable All' }}
</button>
</div>
@if (lastChange()) {
<div style="margin-top: 1rem; padding: 1rem; background: #e3f2fd; border-radius: 4px;">
<strong>Last change:</strong> {{ lastChange() }}
</div>
}
</section>
<!-- Code Examples -->
<section style="margin-bottom: 3rem;">
<h3>Code Examples</h3>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
<h4>Basic Usage:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-textarea
label="Message"
placeholder="Enter your message..."
[(ngModel)]="message"
(textareaChange)="onMessageChange($event)"&gt;
&lt;/ui-textarea&gt;</code></pre>
<h4>With Character Limit:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-textarea
label="Tweet"
placeholder="What's happening?"
[maxLength]="280"
[showCharacterCount]="true"
size="lg"
[(ngModel)]="tweetText"&gt;
&lt;/ui-textarea&gt;</code></pre>
<h4>Auto-resize with Validation:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-textarea
label="Feedback"
[required]="true"
[autoResize]="true"
[clearable]="true"
state="error"
errorMessage="Please provide feedback"
[prefixIcon]="faComment"
[(ngModel)]="feedback"&gt;
&lt;/ui-textarea&gt;</code></pre>
<h4>Resize Options:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>// Resize options:
resize="none" // No resize
resize="vertical" // Vertical only (default)
resize="horizontal" // Horizontal only
resize="both" // Both directions</code></pre>
</div>
</section>
<!-- Current Values -->
<section style="margin-bottom: 3rem;">
<h3>Current Values</h3>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; max-height: 400px; overflow-y: auto;">
<pre>{{ getCurrentValues() }}</pre>
</div>
</section>
</div>
`,
styles: [`
h2 {
color: hsl(279, 14%, 11%);
font-size: 2rem;
margin-bottom: 2rem;
border-bottom: 2px solid hsl(258, 100%, 47%);
padding-bottom: 0.5rem;
}
h3 {
color: hsl(279, 14%, 25%);
font-size: 1.5rem;
margin-bottom: 1rem;
}
h4 {
color: hsl(279, 14%, 35%);
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
pre {
margin: 0;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.4;
}
code {
color: hsl(279, 14%, 15%);
}
section {
border: 1px solid #e9ecef;
padding: 1.5rem;
border-radius: 8px;
background: #ffffff;
}
button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
`]
})
export class TextareaDemoComponent {
// FontAwesome icons
readonly faComment = faComment;
readonly faEdit = faEdit;
readonly faEnvelope = faEnvelope;
// Basic textareas
basicTextarea1 = signal<string>('');
basicTextarea2 = signal<string>('');
basicTextarea3 = signal<string>('This is pre-filled content that demonstrates how the textarea looks with existing text.');
// Size variants
sizeSmall = signal<string>('');
sizeMedium = signal<string>('');
sizeLarge = signal<string>('');
// Style variants
variantOutlined = signal<string>('');
variantFilled = signal<string>('');
variantUnderlined = signal<string>('');
// States
stateDefault = signal<string>('');
stateSuccess = signal<string>('Great job on completing this form!');
stateWarning = signal<string>('Please review this content carefully.');
stateError = signal<string>('This content has some issues.');
// Interactive states
stateDisabled = signal<string>('This content cannot be edited');
stateReadonly = signal<string>('This is read-only information that you can view but not modify');
stateRequired = signal<string>('');
stateLoading = signal<string>('');
// Character limits
charLimit1 = signal<string>('');
charLimit2 = signal<string>('');
charLimit3 = signal<string>('');
// Rows and resize
rowsCompact = signal<string>('');
rowsExpanded = signal<string>('');
autoResizeTextarea = signal<string>('Start typing and watch this textarea grow automatically as you add more content...');
horizontalResize = signal<string>('');
bothResize = signal<string>('');
// Icons and clearable
iconPrefix = signal<string>('');
iconSuffix = signal<string>('');
clearableTextarea = signal<string>('You can clear this text by clicking the X button');
// Control states
globalDisabled = signal<boolean>(false);
lastChange = signal<string>('');
onTextareaChange(textareaId: string, value: string): void {
const preview = value.length > 50 ? value.substring(0, 50) + '...' : value;
this.lastChange.set(`${textareaId}: "${preview}" (${value.length} chars)`);
console.log(`Textarea ${textareaId} changed:`, value);
}
onClear(textareaId: string): void {
this.lastChange.set(`${textareaId}: cleared`);
console.log(`Textarea ${textareaId} cleared`);
}
fillSampleText(): void {
const sampleTexts = [
'This is sample text for demonstration purposes.',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
'Here is some example content to show how the textarea handles different amounts of text.',
'Sample content with multiple lines.\nSecond line of content.\nThird line for testing.',
'Short text',
'A longer piece of sample text that demonstrates how the textarea component handles various lengths of content and provides a good example for testing purposes.'
];
this.basicTextarea1.set(sampleTexts[0]);
this.basicTextarea2.set(sampleTexts[1]);
this.sizeSmall.set(sampleTexts[4]);
this.sizeMedium.set(sampleTexts[2]);
this.sizeLarge.set(sampleTexts[5]);
this.variantOutlined.set(sampleTexts[0]);
this.variantFilled.set(sampleTexts[1]);
this.variantUnderlined.set(sampleTexts[2]);
this.stateDefault.set(sampleTexts[3]);
this.stateRequired.set(sampleTexts[0]);
this.stateLoading.set(sampleTexts[1]);
this.charLimit1.set('Sample text within limit');
this.charLimit2.set('This is a tweet-style message that fits within the character limit.');
this.charLimit3.set(sampleTexts[5]);
this.rowsCompact.set('Compact');
this.rowsExpanded.set(sampleTexts[3]);
this.iconPrefix.set('Comment with icon');
this.iconSuffix.set('Email content here');
this.clearableTextarea.set('This text can be cleared');
this.lastChange.set('All textareas filled with sample text');
}
clearAllTextareas(): void {
this.basicTextarea1.set('');
this.basicTextarea2.set('');
this.sizeSmall.set('');
this.sizeMedium.set('');
this.sizeLarge.set('');
this.variantOutlined.set('');
this.variantFilled.set('');
this.variantUnderlined.set('');
this.stateDefault.set('');
this.stateRequired.set('');
this.stateLoading.set('');
this.charLimit1.set('');
this.charLimit2.set('');
this.charLimit3.set('');
this.rowsCompact.set('');
this.rowsExpanded.set('');
this.autoResizeTextarea.set('');
this.horizontalResize.set('');
this.bothResize.set('');
this.iconPrefix.set('');
this.iconSuffix.set('');
this.clearableTextarea.set('');
this.lastChange.set('All textareas cleared');
}
toggleDisabled(): void {
this.globalDisabled.update(disabled => !disabled);
this.lastChange.set(`Global disabled: ${this.globalDisabled() ? 'enabled' : 'disabled'}`);
}
getCurrentValues(): string {
const values = {
basic: {
textarea1: this.basicTextarea1(),
textarea2: this.basicTextarea2(),
textarea3: this.basicTextarea3()
},
sizes: {
small: this.sizeSmall(),
medium: this.sizeMedium(),
large: this.sizeLarge()
},
variants: {
outlined: this.variantOutlined(),
filled: this.variantFilled(),
underlined: this.variantUnderlined()
},
states: {
default: this.stateDefault(),
success: this.stateSuccess(),
warning: this.stateWarning(),
error: this.stateError(),
disabled: this.stateDisabled(),
readonly: this.stateReadonly(),
required: this.stateRequired(),
loading: this.stateLoading()
},
characterLimits: {
limit100: this.charLimit1(),
limit280: this.charLimit2(),
limit1000: this.charLimit3()
},
rowsAndResize: {
compact: this.rowsCompact(),
expanded: this.rowsExpanded(),
autoResize: this.autoResizeTextarea(),
horizontal: this.horizontalResize(),
both: this.bothResize()
},
iconsAndClearable: {
prefix: this.iconPrefix(),
suffix: this.iconSuffix(),
clearable: this.clearableTextarea()
},
controls: {
globalDisabled: this.globalDisabled(),
lastChange: this.lastChange()
}
};
return JSON.stringify(values, null, 2);
}
}

View File

@@ -11,7 +11,7 @@ import {
faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders,
faMinus, faInfoCircle, faChevronDown, faCaretUp, faExclamationCircle, faSitemap, faStream,
faBell, faRoute, faChevronUp, faEllipsisV, faCut, faPalette, faExchangeAlt, faTools, faHeart, faCircleDot, faColumns,
faFolderOpen, faDesktop, faListUl
faFolderOpen, faDesktop, faListUl, faArrowDown, faThumbtack
} from '@fortawesome/free-solid-svg-icons';
import { DemoRoutes } from '../../demos';
import { ScrollContainerComponent } from "../../../../../ui-essentials/src/public-api";
@@ -115,6 +115,8 @@ export class DashboardComponent {
faDesktop = faDesktop;
faNewspaper = faNewspaper;
faListUl = faListUl;
faArrowDown = faArrowDown;
faThumbtack = faThumbtack;
menuItems: any = []
@@ -235,15 +237,21 @@ export class DashboardComponent {
this.createChildItem("container", "Container", this.faBoxOpen),
this.createChildItem("feed-layout", "Feed Layout", this.faNewspaper),
this.createChildItem("flex", "Flex (Flexbox Container)", this.faArrowsAlt),
this.createChildItem("gallery-grid", "Gallery Grid", this.faImages),
this.createChildItem("grid-system", "Grid System", this.faGripVertical),
this.createChildItem("grid-container", "Grid Container", this.faThLarge),
this.createChildItem("infinite-scroll-container", "Infinite Scroll Container", this.faArrowDown),
this.createChildItem("kanban-board", "Kanban Board Layout", this.faColumns),
this.createChildItem("list-detail-layout", "List Detail Layout", this.faListUl),
this.createChildItem("masonry", "Masonry Layout", this.faThLarge),
this.createChildItem("scroll-container", "Scroll Container", this.faListAlt),
this.createChildItem("supporting-pane-layout", "Supporting Pane Layout", this.faWindowMaximize),
this.createChildItem("section", "Section (Semantic Wrapper)", this.faLayerGroup),
this.createChildItem("sidebar-layout", "Sidebar Layout", this.faBars),
this.createChildItem("spacer", "Spacer", this.faArrowsAlt),
this.createChildItem("split-view", "Split View / Resizable Panels", this.faColumns),
this.createChildItem("stack", "Stack (VStack/HStack)", this.faStream),
this.createChildItem("sticky-layout", "Sticky Layout", this.faThumbtack),
this.createChildItem("tabs-container", "Tabs Container", this.faFolderOpen),
this.createChildItem("dashboard-shell", "Dashboard Shell", this.faDesktop)
];
@@ -263,8 +271,18 @@ export class DashboardComponent {
// Utilities (standalone)
this.addMenuItem("fontawesome", "FontAwesome Icons", this.faCheckSquare);
this.addMenuItem("accessibility", "Accessability", this.faCheckSquare);
this.addMenuItem("animations", "Animations", this.faCheckSquare);
this.addMenuItem("hcl-studio", "HCL Themes", this.faCheckSquare);
this.addMenuItem("font-manager", "Font Manager", this.faCheckSquare);
this.addMenuItem("data-utils", "Data Utils", this.faCheckSquare);
this.addMenuItem("code-display", "Code Display", this.faCheckSquare);
}
menuClick(id: string) {
console.log(JSON.stringify(id))

View File

@@ -47,7 +47,6 @@
.children-container {
margin-left: 1rem;
padding-left: 1rem;
border-left: 2px solid semantic.$semantic-color-border-secondary;
margin-top: 0.25rem;
::ng-deep .child-menu-item {

View File

@@ -11,9 +11,19 @@ $comfortaa-font-path: "/fonts/comfortaa/" !default;
// Import project variables (which now has semantic tokens available)
@import 'scss/_variables';
// Import UI Animations
@import '../../ui-animations/src/styles/index';
html {
height: 100%;
font-size: 16px; // Base font size for rem calculations
// Define default CSS custom properties for fonts
// These can be overridden by the font manager
--font-family-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-family-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-family-mono: ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-family-display: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
body {
@@ -23,8 +33,8 @@ body {
background-color: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
// Apply base typography using semantic tokens
font-family: map-get($semantic-typography-body-medium, font-family);
// Apply base typography using CSS custom properties first, then fallback to semantic tokens
font-family: var(--font-family-sans, map-get($semantic-typography-body-medium, font-family));
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
@@ -32,4 +42,37 @@ body {
// Improve text rendering
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
// Support font transitions
transition: font-family 0.3s ease-in-out;
}
// Global typography classes that respect font variables
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-family-display, var(--font-family-sans));
transition: font-family 0.3s ease-in-out;
}
// Serif elements
blockquote, .serif {
font-family: var(--font-family-serif);
transition: font-family 0.3s ease-in-out;
}
// Monospace elements
code, pre, kbd, samp, .mono {
font-family: var(--font-family-mono);
transition: font-family 0.3s ease-in-out;
}
// Sans-serif elements
.sans {
font-family: var(--font-family-sans);
transition: font-family 0.3s ease-in-out;
}
// Display elements
.display {
font-family: var(--font-family-display);
transition: font-family 0.3s ease-in-out;
}

View File

@@ -0,0 +1,63 @@
# HclStudio
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.0.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the library, run:
```bash
ng build hcl-studio
```
This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
### Publishing the Library
Once the project is built, you can publish your library by following these steps:
1. Navigate to the `dist` directory:
```bash
cd dist/hcl-studio
```
2. Run the `npm publish` command to publish your library to the npm registry:
```bash
npm publish
```
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

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

View File

@@ -0,0 +1,12 @@
{
"name": "hcl-studio",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^19.2.0",
"@angular/core": "^19.2.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}

View File

@@ -0,0 +1,324 @@
/**
* ==========================================================================
* HCL COLOR CONVERTER
* ==========================================================================
* Accurate color space conversions between RGB, LAB, and HCL
* Based on CIE LAB color space for perceptual uniformity
* ==========================================================================
*/
import { RGB, LAB, HCL } from '../models/hcl.models';
export class HCLConverter {
// ==========================================================================
// RGB ↔ HCL CONVERSIONS
// ==========================================================================
/**
* Convert RGB to HCL color space
* @param r Red component (0-255)
* @param g Green component (0-255)
* @param b Blue component (0-255)
* @returns HCL color
*/
static rgbToHcl(r: number, g: number, b: number): HCL {
const lab = this.rgbToLab(r, g, b);
return this.labToHcl(lab.l, lab.a, lab.b);
}
/**
* Convert HCL to RGB color space
* @param h Hue (0-360)
* @param c Chroma (0-100+)
* @param l Lightness (0-100)
* @returns RGB color
*/
static hclToRgb(h: number, c: number, l: number): RGB {
const lab = this.hclToLab(h, c, l);
return this.labToRgb(lab.l, lab.a, lab.b);
}
// ==========================================================================
// HEX CONVERSIONS
// ==========================================================================
/**
* Convert hex string to HCL
* @param hex Hex color string (#RRGGBB or #RGB)
* @returns HCL color
*/
static hexToHcl(hex: string): HCL {
const rgb = this.hexToRgb(hex);
return this.rgbToHcl(rgb.r, rgb.g, rgb.b);
}
/**
* Convert HCL to hex string
* @param h Hue (0-360)
* @param c Chroma (0-100+)
* @param l Lightness (0-100)
* @returns Hex color string
*/
static hclToHex(h: number, c: number, l: number): string {
const rgb = this.hclToRgb(h, c, l);
return this.rgbToHex(rgb.r, rgb.g, rgb.b);
}
// ==========================================================================
// RGB ↔ LAB CONVERSIONS (via XYZ)
// ==========================================================================
/**
* Convert RGB to LAB color space
* @param r Red component (0-255)
* @param g Green component (0-255)
* @param b Blue component (0-255)
* @returns LAB color
*/
static rgbToLab(r: number, g: number, b: number): LAB {
// Convert RGB to XYZ first
let rNorm = r / 255;
let gNorm = g / 255;
let bNorm = b / 255;
// Apply gamma correction (sRGB)
rNorm = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
gNorm = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
bNorm = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
// Convert to XYZ using sRGB matrix
let x = rNorm * 0.4124564 + gNorm * 0.3575761 + bNorm * 0.1804375;
let y = rNorm * 0.2126729 + gNorm * 0.7151522 + bNorm * 0.0721750;
let z = rNorm * 0.0193339 + gNorm * 0.1191920 + bNorm * 0.9503041;
// Normalize by D65 illuminant
x = x / 0.95047;
y = y / 1.00000;
z = z / 1.08883;
// Convert XYZ to LAB
const fx = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x + 16/116);
const fy = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y + 16/116);
const fz = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z + 16/116);
const l = (116 * fy) - 16;
const a = 500 * (fx - fy);
const bValue = 200 * (fy - fz);
return { l: Math.max(0, Math.min(100, l)), a, b: bValue };
}
/**
* Convert LAB to RGB color space
* @param l Lightness (0-100)
* @param a Green-Red axis
* @param b Blue-Yellow axis
* @returns RGB color
*/
static labToRgb(l: number, a: number, b: number): RGB {
// Convert LAB to XYZ
const fy = (l + 16) / 116;
const fx = a / 500 + fy;
const fz = fy - b / 200;
const x = (fx > 0.206897 ? Math.pow(fx, 3) : (fx - 16/116) / 7.787) * 0.95047;
const y = (fy > 0.206897 ? Math.pow(fy, 3) : (fy - 16/116) / 7.787) * 1.00000;
const z = (fz > 0.206897 ? Math.pow(fz, 3) : (fz - 16/116) / 7.787) * 1.08883;
// Convert XYZ to RGB
let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
let g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
let b2 = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
// Apply inverse gamma correction
r = r > 0.0031308 ? 1.055 * Math.pow(r, 1/2.4) - 0.055 : 12.92 * r;
g = g > 0.0031308 ? 1.055 * Math.pow(g, 1/2.4) - 0.055 : 12.92 * g;
b2 = b2 > 0.0031308 ? 1.055 * Math.pow(b2, 1/2.4) - 0.055 : 12.92 * b2;
// Clamp to valid RGB range
const rFinal = Math.max(0, Math.min(255, Math.round(r * 255)));
const gFinal = Math.max(0, Math.min(255, Math.round(g * 255)));
const bFinal = Math.max(0, Math.min(255, Math.round(b2 * 255)));
return { r: rFinal, g: gFinal, b: bFinal };
}
// ==========================================================================
// LAB ↔ HCL CONVERSIONS
// ==========================================================================
/**
* Convert LAB to HCL (cylindrical representation)
* @param l Lightness (0-100)
* @param a Green-Red axis
* @param b Blue-Yellow axis
* @returns HCL color
*/
static labToHcl(l: number, a: number, b: number): HCL {
const c = Math.sqrt(a * a + b * b);
let h = Math.atan2(b, a) * (180 / Math.PI);
// Normalize hue to 0-360
if (h < 0) h += 360;
return {
h: isNaN(h) ? 0 : h,
c: isNaN(c) ? 0 : c,
l: Math.max(0, Math.min(100, l))
};
}
/**
* Convert HCL to LAB color space
* @param h Hue (0-360)
* @param c Chroma (0-100+)
* @param l Lightness (0-100)
* @returns LAB color
*/
static hclToLab(h: number, c: number, l: number): LAB {
const hRad = (h * Math.PI) / 180;
const a = c * Math.cos(hRad);
const b = c * Math.sin(hRad);
return { l, a, b };
}
// ==========================================================================
// HEX UTILITY METHODS
// ==========================================================================
/**
* Convert hex string to RGB
* @param hex Hex color string
* @returns RGB color
*/
static hexToRgb(hex: string): RGB {
// Remove # if present
hex = hex.replace('#', '');
// Handle 3-digit hex
if (hex.length === 3) {
hex = hex.split('').map(h => h + h).join('');
}
if (hex.length !== 6) {
throw new Error(`Invalid hex color: ${hex}`);
}
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return { r, g, b };
}
/**
* Convert RGB to hex string
* @param r Red component (0-255)
* @param g Green component (0-255)
* @param b Blue component (0-255)
* @returns Hex color string
*/
static rgbToHex(r: number, g: number, b: number): string {
const toHex = (n: number) => {
const hex = Math.max(0, Math.min(255, Math.round(n))).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// ==========================================================================
// COLOR MANIPULATION METHODS
// ==========================================================================
/**
* Adjust lightness of a color
* @param hex Original color
* @param amount Lightness adjustment (-100 to 100)
* @returns Adjusted color
*/
static adjustLightness(hex: string, amount: number): string {
const hcl = this.hexToHcl(hex);
const newL = Math.max(0, Math.min(100, hcl.l + amount));
return this.hclToHex(hcl.h, hcl.c, newL);
}
/**
* Adjust chroma (saturation) of a color
* @param hex Original color
* @param amount Chroma adjustment (-100 to 100)
* @returns Adjusted color
*/
static adjustChroma(hex: string, amount: number): string {
const hcl = this.hexToHcl(hex);
const newC = Math.max(0, hcl.c + amount);
return this.hclToHex(hcl.h, newC, hcl.l);
}
/**
* Adjust hue of a color
* @param hex Original color
* @param amount Hue adjustment in degrees (-360 to 360)
* @returns Adjusted color
*/
static adjustHue(hex: string, amount: number): string {
const hcl = this.hexToHcl(hex);
let newH = hcl.h + amount;
// Normalize hue to 0-360
while (newH < 0) newH += 360;
while (newH >= 360) newH -= 360;
return this.hclToHex(newH, hcl.c, hcl.l);
}
// ==========================================================================
// VALIDATION METHODS
// ==========================================================================
/**
* Validate if a string is a valid hex color
* @param hex Color string to validate
* @returns True if valid hex color
*/
static isValidHex(hex: string): boolean {
try {
const normalizedHex = hex.replace('#', '');
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(normalizedHex);
} catch {
return false;
}
}
/**
* Calculate relative luminance of a color (for contrast calculation)
* @param hex Hex color string
* @returns Relative luminance (0-1)
*/
static getRelativeLuminance(hex: string): number {
const rgb = this.hexToRgb(hex);
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* Calculate contrast ratio between two colors
* @param color1 First color
* @param color2 Second color
* @returns Contrast ratio (1-21)
*/
static getContrastRatio(color1: string, color2: string): number {
const lum1 = this.getRelativeLuminance(color1);
const lum2 = this.getRelativeLuminance(color2);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
}

View File

@@ -0,0 +1,353 @@
/**
* ==========================================================================
* PALETTE GENERATOR
* ==========================================================================
* Generate Material Design 3 compliant color palettes using HCL color space
* Creates perceptually uniform tonal variations with proper contrast ratios
* ==========================================================================
*/
import { HCLConverter } from './hcl-converter';
import { TonalPalette, MaterialPalette, BrandColors, HCL } from '../models/hcl.models';
export class PaletteGenerator {
// ==========================================================================
// MATERIAL DESIGN 3 TONE LEVELS
// ==========================================================================
private static readonly TONE_LEVELS = [0, 4, 5, 6, 10, 12, 15, 17, 20, 22, 25, 30, 35, 40, 50, 60, 70, 80, 87, 90, 92, 94, 95, 96, 98, 99, 100] as const;
// ==========================================================================
// MAIN PALETTE GENERATION
// ==========================================================================
/**
* Generate complete Material Design 3 palette from brand colors
* @param brandColors Primary, secondary, tertiary seed colors
* @param mode Light or dark mode
* @returns Complete Material Design 3 palette
*/
static generateMaterialPalette(brandColors: BrandColors, mode: 'light' | 'dark' = 'light'): MaterialPalette {
return {
primary: this.generateTonalPalette(brandColors.primary),
secondary: this.generateTonalPalette(brandColors.secondary),
tertiary: this.generateTonalPalette(brandColors.tertiary),
neutral: this.generateNeutralPalette(brandColors.primary),
neutralVariant: this.generateNeutralVariantPalette(brandColors.primary),
error: this.generateTonalPalette(brandColors.error || '#B3261E') // Material Design default error
};
}
/**
* Generate tonal palette from seed color
* @param seedColor Hex color string
* @returns Tonal palette with all tone levels
*/
static generateTonalPalette(seedColor: string): TonalPalette {
const seedHcl = HCLConverter.hexToHcl(seedColor);
// Create palette object with all tone levels
const palette = {} as TonalPalette;
this.TONE_LEVELS.forEach(tone => {
palette[tone as keyof TonalPalette] = this.generateTone(seedHcl, tone);
});
return palette;
}
/**
* Generate neutral palette from primary color
* @param primaryColor Primary seed color
* @returns Neutral tonal palette
*/
static generateNeutralPalette(primaryColor: string): TonalPalette {
const primaryHcl = HCLConverter.hexToHcl(primaryColor);
// Create neutral variation with reduced chroma and adjusted hue
const neutralHcl: HCL = {
h: primaryHcl.h,
c: Math.min(primaryHcl.c * 0.08, 8), // Very low chroma for neutral
l: primaryHcl.l
};
const palette = {} as TonalPalette;
this.TONE_LEVELS.forEach(tone => {
palette[tone as keyof TonalPalette] = this.generateTone(neutralHcl, tone);
});
return palette;
}
/**
* Generate neutral variant palette from primary color
* @param primaryColor Primary seed color
* @returns Neutral variant tonal palette
*/
static generateNeutralVariantPalette(primaryColor: string): TonalPalette {
const primaryHcl = HCLConverter.hexToHcl(primaryColor);
// Create neutral variant with slightly more chroma than neutral
const neutralVariantHcl: HCL = {
h: primaryHcl.h,
c: Math.min(primaryHcl.c * 0.16, 16), // Low chroma but higher than neutral
l: primaryHcl.l
};
const palette = {} as TonalPalette;
this.TONE_LEVELS.forEach(tone => {
palette[tone as keyof TonalPalette] = this.generateTone(neutralVariantHcl, tone);
});
return palette;
}
// ==========================================================================
// TONE GENERATION
// ==========================================================================
/**
* Generate specific tone from HCL seed
* @param seedHcl Seed color in HCL space
* @param tone Target tone level (0-100)
* @returns Hex color string
*/
private static generateTone(seedHcl: HCL, tone: number): string {
// Apply tone-specific chroma adjustments for better visual consistency
const chromaMultiplier = this.getChromaMultiplier(tone);
const adjustedChroma = seedHcl.c * chromaMultiplier;
// Create tone with adjusted lightness and chroma
const toneHcl: HCL = {
h: seedHcl.h,
c: Math.max(0, adjustedChroma),
l: tone
};
return HCLConverter.hclToHex(toneHcl.h, toneHcl.c, toneHcl.l);
}
/**
* Get chroma multiplier based on tone level
* Reduces chroma at very light and very dark tones for better visual balance
* @param tone Tone level (0-100)
* @returns Chroma multiplier (0-1)
*/
private static getChromaMultiplier(tone: number): number {
// Reduce chroma at extreme lightness values
if (tone <= 10) return 0.6 + (tone / 10) * 0.3; // 0.6 to 0.9
if (tone >= 90) return 1.1 - ((tone - 90) / 10) * 0.5; // 1.1 to 0.6
if (tone >= 80) return 1.0 + ((tone - 80) / 10) * 0.1; // 1.0 to 1.1
// Full chroma in middle ranges
return 1.0;
}
// ==========================================================================
// COLOR HARMONIES
// ==========================================================================
/**
* Generate complementary color from seed
* @param seedColor Seed color
* @returns Complementary color
*/
static generateComplementary(seedColor: string): string {
const hcl = HCLConverter.hexToHcl(seedColor);
const compHue = (hcl.h + 180) % 360;
return HCLConverter.hclToHex(compHue, hcl.c, hcl.l);
}
/**
* Generate analogous colors from seed
* @param seedColor Seed color
* @param count Number of analogous colors to generate
* @returns Array of analogous colors
*/
static generateAnalogous(seedColor: string, count: number = 3): string[] {
const hcl = HCLConverter.hexToHcl(seedColor);
const colors: string[] = [];
const step = 30; // 30-degree steps
for (let i = 0; i < count; i++) {
const hue = (hcl.h + (i - Math.floor(count / 2)) * step) % 360;
const adjustedHue = hue < 0 ? hue + 360 : hue;
colors.push(HCLConverter.hclToHex(adjustedHue, hcl.c, hcl.l));
}
return colors;
}
/**
* Generate triadic colors from seed
* @param seedColor Seed color
* @returns Array of triadic colors (including original)
*/
static generateTriadic(seedColor: string): string[] {
const hcl = HCLConverter.hexToHcl(seedColor);
return [
seedColor,
HCLConverter.hclToHex((hcl.h + 120) % 360, hcl.c, hcl.l),
HCLConverter.hclToHex((hcl.h + 240) % 360, hcl.c, hcl.l)
];
}
/**
* Generate tetradic colors from seed
* @param seedColor Seed color
* @returns Array of tetradic colors
*/
static generateTetradic(seedColor: string): string[] {
const hcl = HCLConverter.hexToHcl(seedColor);
return [
seedColor,
HCLConverter.hclToHex((hcl.h + 90) % 360, hcl.c, hcl.l),
HCLConverter.hclToHex((hcl.h + 180) % 360, hcl.c, hcl.l),
HCLConverter.hclToHex((hcl.h + 270) % 360, hcl.c, hcl.l)
];
}
// ==========================================================================
// ACCESSIBILITY HELPERS
// ==========================================================================
/**
* Ensure minimum contrast ratio for text readability
* @param backgroundColor Background color
* @param textColor Text color
* @param minRatio Minimum contrast ratio (default: 4.5 for WCAG AA)
* @returns Adjusted text color
*/
static ensureContrast(backgroundColor: string, textColor: string, minRatio: number = 4.5): string {
const currentRatio = HCLConverter.getContrastRatio(backgroundColor, textColor);
if (currentRatio >= minRatio) {
return textColor; // Already meets contrast requirements
}
const textHcl = HCLConverter.hexToHcl(textColor);
const backgroundLuminance = HCLConverter.getRelativeLuminance(backgroundColor);
// Adjust lightness to meet contrast requirements
let adjustedL = textHcl.l;
let bestColor = textColor;
let bestRatio = currentRatio;
// Try both lighter and darker variations
for (let lightness = 0; lightness <= 100; lightness += 5) {
const testColor = HCLConverter.hclToHex(textHcl.h, textHcl.c, lightness);
const testRatio = HCLConverter.getContrastRatio(backgroundColor, testColor);
if (testRatio >= minRatio && testRatio > bestRatio) {
bestColor = testColor;
bestRatio = testRatio;
adjustedL = lightness;
}
}
return bestColor;
}
/**
* Get optimal text color (black or white) for background
* @param backgroundColor Background color
* @returns Optimal text color (#000000 or #FFFFFF)
*/
static getOptimalTextColor(backgroundColor: string): string {
const blackRatio = HCLConverter.getContrastRatio(backgroundColor, '#000000');
const whiteRatio = HCLConverter.getContrastRatio(backgroundColor, '#FFFFFF');
return blackRatio > whiteRatio ? '#000000' : '#FFFFFF';
}
// ==========================================================================
// PALETTE UTILITIES
// ==========================================================================
/**
* Get primary tone for specific use case
* @param palette Tonal palette
* @param usage Usage context ('main', 'container', 'on-container', etc.)
* @param mode Light or dark mode
* @returns Appropriate tone
*/
static getPrimaryTone(palette: TonalPalette, usage: 'main' | 'container' | 'on-container' | 'surface', mode: 'light' | 'dark' = 'light'): string {
if (mode === 'light') {
switch (usage) {
case 'main': return palette[40];
case 'container': return palette[90];
case 'on-container': return palette[10];
case 'surface': return palette[98];
default: return palette[40];
}
} else {
switch (usage) {
case 'main': return palette[80];
case 'container': return palette[30];
case 'on-container': return palette[90];
case 'surface': return palette[10];
default: return palette[80];
}
}
}
/**
* Validate palette for accessibility and visual consistency
* @param palette Material palette to validate
* @returns Validation results
*/
static validatePalette(palette: MaterialPalette): { isValid: boolean; issues: string[] } {
const issues: string[] = [];
// Check contrast ratios for common combinations
const primaryMain = palette.primary[40];
const primaryContainer = palette.primary[90];
const onPrimary = palette.primary[100];
const onPrimaryContainer = palette.primary[10];
// Validate primary combinations
const primaryContrastRatio = HCLConverter.getContrastRatio(primaryMain, onPrimary);
if (primaryContrastRatio < 4.5) {
issues.push(`Primary contrast ratio too low: ${primaryContrastRatio.toFixed(2)}`);
}
const containerContrastRatio = HCLConverter.getContrastRatio(primaryContainer, onPrimaryContainer);
if (containerContrastRatio < 4.5) {
issues.push(`Container contrast ratio too low: ${containerContrastRatio.toFixed(2)}`);
}
// Additional checks could be added here (color difference, chroma consistency, etc.)
return {
isValid: issues.length === 0,
issues
};
}
// ==========================================================================
// PREVIEW GENERATION
// ==========================================================================
/**
* Generate color preview for theme selection UI
* @param brandColors Brand colors
* @returns Preview color object
*/
static generateColorPreview(brandColors: BrandColors): { [key: string]: string } {
const palette = this.generateMaterialPalette(brandColors);
return {
'Primary': palette.primary[40],
'Primary Container': palette.primary[90],
'Secondary': palette.secondary[40],
'Secondary Container': palette.secondary[90],
'Tertiary': palette.tertiary[40],
'Tertiary Container': palette.tertiary[90],
'Surface': palette.neutral[98],
'Surface Container': palette.neutral[94]
};
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HclStudioComponent } from './hcl-studio.component';
describe('HclStudioComponent', () => {
let component: HclStudioComponent;
let fixture: ComponentFixture<HclStudioComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HclStudioComponent]
})
.compileComponents();
fixture = TestBed.createComponent(HclStudioComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
@Component({
selector: 'lib-hcl-studio',
imports: [],
template: `
<p>
hcl-studio works!
</p>
`,
styles: ``
})
export class HclStudioComponent {
}

View File

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

View File

@@ -0,0 +1,512 @@
/**
* ==========================================================================
* HCL STUDIO SERVICE
* ==========================================================================
* Main service for HCL-based color palette generation and theme management
* Provides complete theme switching, persistence, and reactive updates
* ==========================================================================
*/
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import {
ThemeConfig,
Theme,
BrandColors,
MaterialPalette,
ThemePreview,
ThemeCollection,
ThemeState,
HCLStudioOptions,
ColorValidation
} from './models/hcl.models';
import { PaletteGenerator } from './core/palette-generator';
import { HCLConverter } from './core/hcl-converter';
import { CSSVariableManager } from './themes/css-variable-manager';
import { ThemeStorage } from './themes/theme-storage';
@Injectable({
providedIn: 'root'
})
export class HCLStudioService {
// ==========================================================================
// REACTIVE STATE
// ==========================================================================
private _themeState$ = new BehaviorSubject<ThemeState>({
currentTheme: null,
availableThemes: [],
mode: 'light',
isInitialized: false
});
private _currentMode$ = new BehaviorSubject<'light' | 'dark'>('light');
// Public observables
public readonly themeState$: Observable<ThemeState> = this._themeState$.asObservable();
public readonly currentMode$: Observable<'light' | 'dark'> = this._currentMode$.asObservable();
// ==========================================================================
// PRIVATE STATE
// ==========================================================================
private builtInThemes = new Map<string, Theme>();
private customThemes = new Map<string, Theme>();
private currentTheme: Theme | null = null;
private currentMode: 'light' | 'dark' = 'light';
private options: HCLStudioOptions = {};
// ==========================================================================
// INITIALIZATION
// ==========================================================================
/**
* Initialize HCL Studio with options and themes
* @param options Initialization options
*/
initialize(options: HCLStudioOptions = {}): void {
this.options = {
autoMode: false,
storageKey: 'hcl-studio',
validation: {
enableContrastCheck: true,
enableColorBlindCheck: false,
minContrastRatio: 4.5
},
...options
};
// Load built-in themes if provided
if (options.themes) {
this.registerThemes(options.themes);
}
// Load custom themes from storage
this.loadCustomThemesFromStorage();
// Restore user's last theme and mode
this.restoreUserPreferences();
// Apply default theme if specified
if (options.defaultTheme) {
if (typeof options.defaultTheme === 'string') {
this.switchTheme(options.defaultTheme);
} else {
this.applyThemeConfig(options.defaultTheme);
}
}
// Set up system theme detection if enabled
if (options.autoMode) {
this.setupAutoModeDetection();
}
this.updateThemeState();
this.markAsInitialized();
}
/**
* Quick initialize with brand colors
* @param brandColors Primary, secondary, tertiary colors
* @param mode Initial mode
*/
initializeWithColors(brandColors: BrandColors, mode: 'light' | 'dark' = 'light'): void {
const themeConfig: ThemeConfig = {
id: 'brand-theme',
name: 'Brand Theme',
colors: brandColors,
mode,
createdAt: new Date()
};
this.initialize({
defaultTheme: themeConfig,
autoMode: false
});
}
// ==========================================================================
// THEME MANAGEMENT
// ==========================================================================
/**
* Register multiple themes
* @param themes Theme collection
*/
registerThemes(themes: ThemeCollection): void {
Object.entries(themes).forEach(([id, config]) => {
const themeConfig: ThemeConfig = { id, ...config };
const theme = this.createCompleteTheme(themeConfig);
this.builtInThemes.set(id, theme);
});
this.updateThemeState();
}
/**
* Create and save custom theme
* @param id Theme identifier
* @param name Theme display name
* @param brandColors Brand colors
* @param description Optional description
* @returns Created theme
*/
createCustomTheme(id: string, name: string, brandColors: BrandColors, description?: string): Theme {
const themeConfig: ThemeConfig = {
id,
name,
colors: brandColors,
description,
createdAt: new Date()
};
const theme = this.createCompleteTheme(themeConfig);
this.customThemes.set(id, theme);
// Save to storage
ThemeStorage.saveCustomTheme(themeConfig);
this.updateThemeState();
return theme;
}
/**
* Switch to different theme
* @param themeId Theme identifier
* @param mode Optional mode override
*/
switchTheme(themeId: string, mode?: 'light' | 'dark'): void {
const theme = this.getTheme(themeId);
if (!theme) {
console.warn(`Theme not found: ${themeId}`);
return;
}
this.currentTheme = theme;
if (mode) {
this.currentMode = mode;
this._currentMode$.next(mode);
ThemeStorage.saveCurrentMode(mode);
}
// Apply the palette
const palette = this.currentMode === 'light' ? theme.lightPalette : theme.darkPalette;
CSSVariableManager.applyPaletteWithTransition(palette, this.currentMode);
// Save preference
ThemeStorage.saveCurrentTheme(themeId);
this.updateThemeState();
}
/**
* Apply theme configuration directly
* @param themeConfig Theme configuration
*/
applyThemeConfig(themeConfig: ThemeConfig): void {
const theme = this.createCompleteTheme(themeConfig);
this.currentTheme = theme;
const resolvedMode = themeConfig.mode === 'auto' ? this.currentMode : (themeConfig.mode || this.currentMode);
const palette = resolvedMode === 'light' ? theme.lightPalette : theme.darkPalette;
CSSVariableManager.applyPaletteWithTransition(palette, resolvedMode);
this.currentMode = resolvedMode;
this._currentMode$.next(resolvedMode);
this.updateThemeState();
}
/**
* Toggle between light and dark mode
*/
toggleMode(): void {
const newMode = this.currentMode === 'light' ? 'dark' : 'light';
this.switchMode(newMode);
}
/**
* Switch to specific mode
* @param mode Target mode
*/
switchMode(mode: 'light' | 'dark'): void {
if (this.currentMode === mode) return;
this.currentMode = mode;
this._currentMode$.next(mode);
if (this.currentTheme) {
const palette = mode === 'light' ? this.currentTheme.lightPalette : this.currentTheme.darkPalette;
CSSVariableManager.applyPaletteWithTransition(palette, mode);
}
ThemeStorage.saveCurrentMode(mode);
this.updateThemeState();
}
// ==========================================================================
// COLOR UTILITIES
// ==========================================================================
/**
* Generate color preview for theme selection
* @param brandColors Brand colors
* @returns Preview color object
*/
generateColorPreview(brandColors: BrandColors): { [key: string]: string } {
return PaletteGenerator.generateColorPreview(brandColors);
}
/**
* Validate color format and accessibility
* @param color Color string to validate
* @returns Validation results
*/
validateColor(color: string): ColorValidation {
const warnings: string[] = [];
const recommendations: string[] = [];
// Check format validity
if (!HCLConverter.isValidHex(color)) {
warnings.push('Invalid hex color format');
return { isValid: false, warnings, recommendations };
}
// Check if color is too light/dark for accessibility
const hcl = HCLConverter.hexToHcl(color);
if (hcl.l < 10) {
warnings.push('Color may be too dark for some use cases');
recommendations.push('Consider using lighter tones for better contrast');
}
if (hcl.l > 90) {
warnings.push('Color may be too light for some use cases');
recommendations.push('Consider using darker tones for better contrast');
}
// Check chroma levels
if (hcl.c < 10) {
recommendations.push('Low chroma - color may appear washed out');
}
if (hcl.c > 80) {
recommendations.push('High chroma - consider reducing saturation for better balance');
}
return {
isValid: warnings.length === 0,
warnings,
recommendations: recommendations.length > 0 ? recommendations : undefined
};
}
// ==========================================================================
// GETTERS
// ==========================================================================
/**
* Get current theme state
* @returns Current theme state
*/
getCurrentThemeState(): ThemeState {
return this._themeState$.value;
}
/**
* Get current theme
* @returns Current theme or null
*/
getCurrentTheme(): Theme | null {
return this.currentTheme;
}
/**
* Get current mode
* @returns Current mode
*/
getCurrentMode(): 'light' | 'dark' {
return this.currentMode;
}
/**
* Get all available themes
* @returns Array of theme previews
*/
getAvailableThemes(): ThemePreview[] {
const themes: ThemePreview[] = [];
// Add built-in themes
this.builtInThemes.forEach(theme => {
themes.push({
id: theme.config.id,
name: theme.config.name,
description: theme.config.description,
primary: theme.config.colors.primary,
secondary: theme.config.colors.secondary,
tertiary: theme.config.colors.tertiary
});
});
// Add custom themes
this.customThemes.forEach(theme => {
themes.push({
id: theme.config.id,
name: theme.config.name,
description: theme.config.description,
primary: theme.config.colors.primary,
secondary: theme.config.colors.secondary,
tertiary: theme.config.colors.tertiary
});
});
return themes;
}
/**
* Check if service is initialized
* @returns True if initialized
*/
isInitialized(): boolean {
return this._themeState$.value.isInitialized;
}
// ==========================================================================
// IMPORT/EXPORT
// ==========================================================================
/**
* Export theme configuration
* @param themeId Theme to export
* @returns JSON string or null
*/
exportTheme(themeId: string): string | null {
return ThemeStorage.exportTheme(themeId);
}
/**
* Import theme from JSON
* @param jsonString Theme JSON data
* @returns Imported theme or null
*/
importTheme(jsonString: string): ThemeConfig | null {
const imported = ThemeStorage.importTheme(jsonString);
if (imported) {
// Reload custom themes to include the new one
this.loadCustomThemesFromStorage();
this.updateThemeState();
}
return imported;
}
// ==========================================================================
// PRIVATE METHODS
// ==========================================================================
/**
* Create complete theme with both light and dark palettes
* @param config Theme configuration
* @returns Complete theme
*/
private createCompleteTheme(config: ThemeConfig): Theme {
const lightPalette = PaletteGenerator.generateMaterialPalette(config.colors, 'light');
const darkPalette = PaletteGenerator.generateMaterialPalette(config.colors, 'dark');
const lightCSSVariables = CSSVariableManager.generateCSSVariables(lightPalette, 'light');
const darkCSSVariables = CSSVariableManager.generateCSSVariables(darkPalette, 'dark');
return {
config,
lightPalette,
darkPalette,
cssVariables: lightCSSVariables // Default to light mode variables
};
}
/**
* Get theme by ID (checks both built-in and custom)
* @param themeId Theme identifier
* @returns Theme or null
*/
private getTheme(themeId: string): Theme | null {
return this.builtInThemes.get(themeId) || this.customThemes.get(themeId) || null;
}
/**
* Load custom themes from storage
*/
private loadCustomThemesFromStorage(): void {
const customThemes = ThemeStorage.getCustomThemes();
Object.values(customThemes).forEach(config => {
const theme = this.createCompleteTheme(config);
this.customThemes.set(config.id, theme);
});
}
/**
* Restore user's theme preferences
*/
private restoreUserPreferences(): void {
const savedThemeId = ThemeStorage.getCurrentTheme();
const savedMode = ThemeStorage.getCurrentMode();
this.currentMode = savedMode;
this._currentMode$.next(savedMode);
if (savedThemeId && this.getTheme(savedThemeId)) {
this.switchTheme(savedThemeId, savedMode);
}
}
/**
* Set up automatic mode detection based on system preference
*/
private setupAutoModeDetection(): void {
if (typeof window !== 'undefined' && window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const systemMode = e.matches ? 'dark' : 'light';
this.switchMode(systemMode);
};
// Set initial mode
const initialSystemMode = mediaQuery.matches ? 'dark' : 'light';
this.switchMode(initialSystemMode);
// Listen for changes
mediaQuery.addEventListener('change', handleChange);
}
}
/**
* Update reactive theme state
*/
private updateThemeState(): void {
const state: ThemeState = {
currentTheme: this.currentTheme,
availableThemes: this.getAvailableThemes(),
mode: this.currentMode,
isInitialized: this._themeState$.value.isInitialized
};
this._themeState$.next(state);
}
/**
* Mark service as initialized
*/
private markAsInitialized(): void {
const currentState = this._themeState$.value;
this._themeState$.next({
...currentState,
isInitialized: true
});
}
}

View File

@@ -0,0 +1,239 @@
/**
* ==========================================================================
* HCL STUDIO - TYPE DEFINITIONS
* ==========================================================================
* Core interfaces and types for HCL color manipulation and theme management
* ==========================================================================
*/
// ==========================================================================
// COLOR TYPES
// ==========================================================================
/**
* HCL color representation
*/
export interface HCL {
/** Hue (0-360 degrees) */
h: number;
/** Chroma (0-100+) */
c: number;
/** Lightness (0-100) */
l: number;
}
/**
* RGB color representation
*/
export interface RGB {
/** Red (0-255) */
r: number;
/** Green (0-255) */
g: number;
/** Blue (0-255) */
b: number;
}
/**
* LAB color representation (intermediate for HCL conversion)
*/
export interface LAB {
/** Lightness (0-100) */
l: number;
/** Green-Red axis (-128 to 127) */
a: number;
/** Blue-Yellow axis (-128 to 127) */
b: number;
}
// ==========================================================================
// PALETTE TYPES
// ==========================================================================
/**
* Tonal palette with Material Design 3 tone levels
*/
export interface TonalPalette {
0: string;
4: string;
5: string;
6: string;
10: string;
12: string;
15: string;
17: string;
20: string;
22: string;
25: string;
30: string;
35: string;
40: string;
50: string;
60: string;
70: string;
80: string;
87: string;
90: string;
92: string;
94: string;
95: string;
96: string;
98: string;
99: string;
100: string;
}
/**
* Complete Material Design 3 color palette
*/
export interface MaterialPalette {
primary: TonalPalette;
secondary: TonalPalette;
tertiary: TonalPalette;
neutral: TonalPalette;
neutralVariant: TonalPalette;
error: TonalPalette;
}
/**
* Brand seed colors
*/
export interface BrandColors {
primary: string;
secondary: string;
tertiary: string;
error?: string;
}
// ==========================================================================
// THEME TYPES
// ==========================================================================
/**
* Theme configuration
*/
export interface ThemeConfig {
id: string;
name: string;
colors: BrandColors;
mode?: 'light' | 'dark' | 'auto';
createdAt?: Date;
description?: string;
}
/**
* Complete theme with generated palettes
*/
export interface Theme {
config: ThemeConfig;
lightPalette: MaterialPalette;
darkPalette: MaterialPalette;
cssVariables: CSSVariables;
}
/**
* CSS custom properties mapping
*/
export interface CSSVariables {
[key: string]: string;
}
/**
* Theme preview information
*/
export interface ThemePreview {
id: string;
name: string;
description?: string;
primary: string;
secondary: string;
tertiary: string;
}
/**
* Theme collection
*/
export interface ThemeCollection {
[themeId: string]: Omit<ThemeConfig, 'id'>;
}
// ==========================================================================
// VALIDATION TYPES
// ==========================================================================
/**
* Color validation result
*/
export interface ColorValidation {
isValid: boolean;
warnings: string[];
recommendations?: string[];
accessibility?: AccessibilityInfo;
}
/**
* Accessibility information
*/
export interface AccessibilityInfo {
contrastRatio: number;
wcagLevel: 'AA' | 'AAA' | 'fail';
colorBlindSafe: boolean;
}
// ==========================================================================
// SERVICE CONFIGURATION TYPES
// ==========================================================================
/**
* HCL Studio initialization options
*/
export interface HCLStudioOptions {
/** Default theme to apply */
defaultTheme?: string | ThemeConfig;
/** Enable automatic mode switching based on system preference */
autoMode?: boolean;
/** Storage key for theme persistence */
storageKey?: string;
/** Custom theme presets */
themes?: ThemeCollection;
/** Validation options */
validation?: {
enableContrastCheck: boolean;
enableColorBlindCheck: boolean;
minContrastRatio: number;
};
}
/**
* Theme state for reactive updates
*/
export interface ThemeState {
currentTheme: Theme | null;
availableThemes: ThemePreview[];
mode: 'light' | 'dark';
isInitialized: boolean;
}
// ==========================================================================
// UTILITY TYPES
// ==========================================================================
/**
* Color format types
*/
export type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hcl';
/**
* Mode switching options
*/
export type ThemeMode = 'light' | 'dark' | 'auto';
/**
* Contrast levels for WCAG compliance
*/
export type ContrastLevel = 'AA' | 'AAA';
/**
* Color harmony types
*/
export type ColorHarmony = 'complementary' | 'analogous' | 'triadic' | 'tetradic' | 'monochromatic';

View File

@@ -0,0 +1,320 @@
/**
* ==========================================================================
* CSS VARIABLE MANAGER
* ==========================================================================
* Manages CSS custom properties for dynamic theme switching
* Maps Material Design 3 palette to existing design system variables
* ==========================================================================
*/
import { MaterialPalette, CSSVariables } from '../models/hcl.models';
import { PaletteGenerator } from '../core/palette-generator';
export class CSSVariableManager {
// ==========================================================================
// VARIABLE MAPPING
// ==========================================================================
/**
* Generate CSS variables from Material Design 3 palette
* @param palette Material palette
* @param mode Light or dark mode
* @returns CSS variables object
*/
static generateCSSVariables(palette: MaterialPalette, mode: 'light' | 'dark' = 'light'): CSSVariables {
const variables: CSSVariables = {};
// ==========================================================================
// PRIMARY COLORS
// ==========================================================================
variables['--color-primary-key'] = palette.primary[40];
variables['--color-primary'] = PaletteGenerator.getPrimaryTone(palette.primary, 'main', mode);
variables['--color-on-primary'] = mode === 'light' ? palette.primary[100] : palette.primary[20];
variables['--color-primary-container'] = PaletteGenerator.getPrimaryTone(palette.primary, 'container', mode);
variables['--color-on-primary-container'] = PaletteGenerator.getPrimaryTone(palette.primary, 'on-container', mode);
variables['--color-inverse-primary'] = mode === 'light' ? palette.primary[80] : palette.primary[40];
// ==========================================================================
// SECONDARY COLORS
// ==========================================================================
variables['--color-secondary-key'] = palette.secondary[40];
variables['--color-secondary'] = mode === 'light' ? palette.secondary[40] : palette.secondary[80];
variables['--color-on-secondary'] = mode === 'light' ? palette.secondary[100] : palette.secondary[20];
variables['--color-secondary-container'] = mode === 'light' ? palette.secondary[90] : palette.secondary[30];
variables['--color-on-secondary-container'] = mode === 'light' ? palette.secondary[10] : palette.secondary[90];
// ==========================================================================
// TERTIARY COLORS
// ==========================================================================
variables['--color-tertiary-key'] = palette.tertiary[40];
variables['--color-tertiary'] = mode === 'light' ? palette.tertiary[40] : palette.tertiary[80];
variables['--color-on-tertiary'] = mode === 'light' ? palette.tertiary[100] : palette.tertiary[20];
variables['--color-tertiary-container'] = mode === 'light' ? palette.tertiary[90] : palette.tertiary[30];
variables['--color-on-tertiary-container'] = mode === 'light' ? palette.tertiary[10] : palette.tertiary[90];
// ==========================================================================
// ERROR COLORS
// ==========================================================================
variables['--color-error-key'] = palette.error[40];
variables['--color-error'] = mode === 'light' ? palette.error[40] : palette.error[80];
variables['--color-on-error'] = mode === 'light' ? palette.error[100] : palette.error[20];
variables['--color-error-container'] = mode === 'light' ? palette.error[90] : palette.error[30];
variables['--color-on-error-container'] = mode === 'light' ? palette.error[10] : palette.error[90];
// ==========================================================================
// NEUTRAL COLORS
// ==========================================================================
variables['--color-neutral-key'] = palette.neutral[10];
variables['--color-neutral'] = mode === 'light' ? palette.neutral[25] : palette.neutral[80];
variables['--color-neutral-variant-key'] = palette.neutralVariant[15];
variables['--color-neutral-variant'] = mode === 'light' ? palette.neutralVariant[30] : palette.neutralVariant[70];
// ==========================================================================
// SURFACE COLORS (The most important for your existing system)
// ==========================================================================
if (mode === 'light') {
variables['--color-surface'] = palette.neutral[99];
variables['--color-surface-bright'] = palette.neutral[99];
variables['--color-surface-dim'] = palette.neutral[87];
variables['--color-on-surface'] = palette.neutral[10];
variables['--color-surface-lowest'] = palette.neutral[100];
variables['--color-surface-low'] = palette.neutral[96];
variables['--color-surface-container'] = palette.neutral[94];
variables['--color-surface-high'] = palette.neutral[92];
variables['--color-surface-highest'] = palette.neutral[90];
variables['--color-surface-variant'] = palette.neutralVariant[90];
variables['--color-on-surface-variant'] = palette.neutralVariant[30];
variables['--color-inverse-surface'] = palette.neutral[20];
variables['--color-inverse-on-surface'] = palette.neutral[95];
} else {
variables['--color-surface'] = palette.neutral[10];
variables['--color-surface-bright'] = palette.neutral[20];
variables['--color-surface-dim'] = palette.neutral[6];
variables['--color-on-surface'] = palette.neutral[90];
variables['--color-surface-lowest'] = palette.neutral[0];
variables['--color-surface-low'] = palette.neutral[4];
variables['--color-surface-container'] = palette.neutral[12];
variables['--color-surface-high'] = palette.neutral[17];
variables['--color-surface-highest'] = palette.neutral[22];
variables['--color-surface-variant'] = palette.neutralVariant[30];
variables['--color-on-surface-variant'] = palette.neutralVariant[80];
variables['--color-inverse-surface'] = palette.neutral[90];
variables['--color-inverse-on-surface'] = palette.neutral[20];
}
// ==========================================================================
// BACKGROUND COLORS
// ==========================================================================
variables['--color-background'] = variables['--color-surface'];
variables['--color-on-background'] = variables['--color-on-surface'];
// ==========================================================================
// OUTLINE COLORS
// ==========================================================================
variables['--color-outline'] = mode === 'light' ? palette.neutralVariant[50] : palette.neutralVariant[60];
variables['--color-outline-variant'] = mode === 'light' ? palette.neutralVariant[80] : palette.neutralVariant[30];
// ==========================================================================
// UTILITY COLORS
// ==========================================================================
variables['--color-shadow'] = '#000000';
variables['--color-surface-tint'] = variables['--color-primary'];
variables['--color-scrim'] = '#000000';
return variables;
}
// ==========================================================================
// APPLY METHODS
// ==========================================================================
/**
* Apply CSS variables to document root
* @param variables CSS variables to apply
*/
static applyVariables(variables: CSSVariables): void {
const root = document.documentElement;
Object.entries(variables).forEach(([property, value]) => {
root.style.setProperty(property, value);
});
}
/**
* Apply palette to document root
* @param palette Material palette
* @param mode Light or dark mode
*/
static applyPalette(palette: MaterialPalette, mode: 'light' | 'dark' = 'light'): void {
const variables = this.generateCSSVariables(palette, mode);
this.applyVariables(variables);
}
/**
* Remove all theme-related CSS variables
*/
static clearVariables(): void {
const root = document.documentElement;
const variableNames = this.getAllVariableNames();
variableNames.forEach(name => {
root.style.removeProperty(name);
});
}
// ==========================================================================
// UTILITY METHODS
// ==========================================================================
/**
* Get all CSS variable names managed by HCL Studio
* @returns Array of CSS variable names
*/
private static getAllVariableNames(): string[] {
return [
// Primary
'--color-primary-key',
'--color-primary',
'--color-on-primary',
'--color-primary-container',
'--color-on-primary-container',
'--color-inverse-primary',
// Secondary
'--color-secondary-key',
'--color-secondary',
'--color-on-secondary',
'--color-secondary-container',
'--color-on-secondary-container',
// Tertiary
'--color-tertiary-key',
'--color-tertiary',
'--color-on-tertiary',
'--color-tertiary-container',
'--color-on-tertiary-container',
// Error
'--color-error-key',
'--color-error',
'--color-on-error',
'--color-error-container',
'--color-on-error-container',
// Neutral
'--color-neutral-key',
'--color-neutral',
'--color-neutral-variant-key',
'--color-neutral-variant',
// Surface
'--color-surface',
'--color-surface-bright',
'--color-surface-dim',
'--color-on-surface',
'--color-surface-lowest',
'--color-surface-low',
'--color-surface-container',
'--color-surface-high',
'--color-surface-highest',
'--color-surface-variant',
'--color-on-surface-variant',
'--color-inverse-surface',
'--color-inverse-on-surface',
// Background
'--color-background',
'--color-on-background',
// Outline
'--color-outline',
'--color-outline-variant',
// Utility
'--color-shadow',
'--color-surface-tint',
'--color-scrim'
];
}
/**
* Get current CSS variable value
* @param variableName CSS variable name (with or without --)
* @returns Current variable value
*/
static getCurrentVariableValue(variableName: string): string {
const name = variableName.startsWith('--') ? variableName : `--${variableName}`;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/**
* Check if dark mode is currently active
* @returns True if dark mode is active
*/
static isDarkModeActive(): boolean {
// Check if surface color is dark (low lightness)
const surfaceColor = this.getCurrentVariableValue('--color-surface');
if (!surfaceColor) return false;
try {
// Simple check: if surface is closer to black than white, it's dark mode
const surfaceHex = surfaceColor.startsWith('#') ? surfaceColor : '#FFFFFF';
const surfaceRgb = this.hexToRgb(surfaceHex);
const brightness = (surfaceRgb.r * 299 + surfaceRgb.g * 587 + surfaceRgb.b * 114) / 1000;
return brightness < 128;
} catch {
return false;
}
}
/**
* Simple hex to RGB conversion for utility
* @param hex Hex color
* @returns RGB values
*/
private static hexToRgb(hex: string): { r: number, g: number, b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 };
}
// ==========================================================================
// ANIMATION SUPPORT
// ==========================================================================
/**
* Apply variables with smooth transition
* @param variables CSS variables to apply
* @param duration Transition duration in milliseconds
*/
static applyVariablesWithTransition(variables: CSSVariables, duration: number = 300): void {
const root = document.documentElement;
// Add transition for smooth theme changes
const transitionProperties = Object.keys(variables).join(', ');
root.style.setProperty('transition', `${transitionProperties} ${duration}ms ease-in-out`);
// Apply variables
this.applyVariables(variables);
// Remove transition after animation completes
setTimeout(() => {
root.style.removeProperty('transition');
}, duration);
}
/**
* Apply palette with smooth transition
* @param palette Material palette
* @param mode Light or dark mode
* @param duration Transition duration in milliseconds
*/
static applyPaletteWithTransition(palette: MaterialPalette, mode: 'light' | 'dark' = 'light', duration: number = 300): void {
const variables = this.generateCSSVariables(palette, mode);
this.applyVariablesWithTransition(variables, duration);
}
}

View File

@@ -0,0 +1,357 @@
/**
* ==========================================================================
* DEFAULT THEME PRESETS
* ==========================================================================
* Built-in theme configurations with carefully selected color combinations
* Provides professionally designed themes for immediate use
* ==========================================================================
*/
import { ThemeCollection } from '../models/hcl.models';
/**
* Default theme collection with professionally curated color combinations
*/
export const DEFAULT_THEMES: ThemeCollection = {
// ==========================================================================
// MATERIAL DESIGN THEMES
// ==========================================================================
'material-purple': {
name: 'Material Purple',
description: 'Classic Material Design purple theme',
colors: {
primary: '#6750A4',
secondary: '#625B71',
tertiary: '#7D5260'
}
},
'material-blue': {
name: 'Material Blue',
description: 'Material Design blue theme',
colors: {
primary: '#1976D2',
secondary: '#455A64',
tertiary: '#7B1FA2'
}
},
'material-green': {
name: 'Material Green',
description: 'Material Design green theme',
colors: {
primary: '#2E7D32',
secondary: '#5D4037',
tertiary: '#1976D2'
}
},
// ==========================================================================
// CORPORATE/BUSINESS THEMES
// ==========================================================================
'corporate-blue': {
name: 'Corporate Blue',
description: 'Professional blue theme for business applications',
colors: {
primary: '#1565C0',
secondary: '#37474F',
tertiary: '#5E35B1'
}
},
'executive': {
name: 'Executive',
description: 'Sophisticated theme with navy and gold accents',
colors: {
primary: '#263238',
secondary: '#455A64',
tertiary: '#FF8F00'
}
},
'modern-corporate': {
name: 'Modern Corporate',
description: 'Contemporary corporate theme with teal accents',
colors: {
primary: '#00695C',
secondary: '#424242',
tertiary: '#7B1FA2'
}
},
// ==========================================================================
// NATURE-INSPIRED THEMES
// ==========================================================================
'forest': {
name: 'Forest',
description: 'Deep greens inspired by forest landscapes',
colors: {
primary: '#2E7D32',
secondary: '#5D4037',
tertiary: '#FF6F00'
}
},
'ocean': {
name: 'Ocean',
description: 'Cool blues and teals reminiscent of ocean depths',
colors: {
primary: '#006064',
secondary: '#263238',
tertiary: '#00ACC1'
}
},
'sunset': {
name: 'Sunset',
description: 'Warm oranges and deep purples of a sunset sky',
colors: {
primary: '#FF6F00',
secondary: '#5D4037',
tertiary: '#7B1FA2'
}
},
'lavender': {
name: 'Lavender Fields',
description: 'Soft purples and greens inspired by lavender fields',
colors: {
primary: '#7E57C2',
secondary: '#689F38',
tertiary: '#8BC34A'
}
},
// ==========================================================================
// VIBRANT/CREATIVE THEMES
// ==========================================================================
'electric': {
name: 'Electric',
description: 'High-energy theme with electric blues and purples',
colors: {
primary: '#3F51B5',
secondary: '#9C27B0',
tertiary: '#00BCD4'
}
},
'neon': {
name: 'Neon',
description: 'Bold neon-inspired theme for creative applications',
colors: {
primary: '#E91E63',
secondary: '#9C27B0',
tertiary: '#00BCD4'
}
},
'cyberpunk': {
name: 'Cyberpunk',
description: 'Futuristic theme with cyan and magenta accents',
colors: {
primary: '#00E5FF',
secondary: '#263238',
tertiary: '#FF1744'
}
},
// ==========================================================================
// WARM/COZY THEMES
// ==========================================================================
'autumn': {
name: 'Autumn',
description: 'Warm autumn colors with orange and brown tones',
colors: {
primary: '#FF5722',
secondary: '#5D4037',
tertiary: '#FF9800'
}
},
'coffee': {
name: 'Coffee House',
description: 'Rich browns and warm tones for a cozy feel',
colors: {
primary: '#5D4037',
secondary: '#8D6E63',
tertiary: '#FF8F00'
}
},
'golden': {
name: 'Golden',
description: 'Luxurious theme with gold and deep blue accents',
colors: {
primary: '#FF8F00',
secondary: '#1565C0',
tertiary: '#7B1FA2'
}
},
// ==========================================================================
// MONOCHROMATIC THEMES
// ==========================================================================
'midnight': {
name: 'Midnight',
description: 'Dark theme with subtle blue undertones',
colors: {
primary: '#1A237E',
secondary: '#283593',
tertiary: '#3F51B5'
}
},
'slate': {
name: 'Slate',
description: 'Sophisticated grays with blue-gray undertones',
colors: {
primary: '#455A64',
secondary: '#607D8B',
tertiary: '#90A4AE'
}
},
'charcoal': {
name: 'Charcoal',
description: 'Dark charcoal theme with minimal color accents',
colors: {
primary: '#263238',
secondary: '#37474F',
tertiary: '#546E7A'
}
},
// ==========================================================================
// PASTEL/SOFT THEMES
// ==========================================================================
'soft-pink': {
name: 'Soft Pink',
description: 'Gentle theme with soft pink and lavender tones',
colors: {
primary: '#AD1457',
secondary: '#7B1FA2',
tertiary: '#512DA8'
}
},
'mint': {
name: 'Mint',
description: 'Fresh theme with mint greens and soft blues',
colors: {
primary: '#00695C',
secondary: '#00838F',
tertiary: '#0277BD'
}
},
'cream': {
name: 'Cream',
description: 'Warm, soft theme with cream and beige tones',
colors: {
primary: '#8D6E63',
secondary: '#A1887F',
tertiary: '#FF8A65'
}
},
// ==========================================================================
// HIGH CONTRAST THEMES
// ==========================================================================
'high-contrast': {
name: 'High Contrast',
description: 'Maximum contrast theme for accessibility',
colors: {
primary: '#000000',
secondary: '#424242',
tertiary: '#757575'
}
},
'accessibility': {
name: 'Accessibility',
description: 'Optimized for maximum readability and contrast',
colors: {
primary: '#1565C0',
secondary: '#0D47A1',
tertiary: '#1A237E'
}
}
};
/**
* Get theme by category
*/
export const getThemesByCategory = () => {
return {
material: ['material-purple', 'material-blue', 'material-green'],
corporate: ['corporate-blue', 'executive', 'modern-corporate'],
nature: ['forest', 'ocean', 'sunset', 'lavender'],
vibrant: ['electric', 'neon', 'cyberpunk'],
warm: ['autumn', 'coffee', 'golden'],
monochrome: ['midnight', 'slate', 'charcoal'],
pastel: ['soft-pink', 'mint', 'cream'],
accessibility: ['high-contrast', 'accessibility']
};
};
/**
* Get recommended themes for specific use cases
*/
export const getRecommendedThemes = () => {
return {
business: ['corporate-blue', 'executive', 'modern-corporate', 'slate'],
creative: ['electric', 'neon', 'cyberpunk', 'sunset'],
healthcare: ['soft-pink', 'mint', 'ocean', 'accessibility'],
finance: ['corporate-blue', 'executive', 'charcoal', 'midnight'],
education: ['material-blue', 'forest', 'accessibility', 'mint'],
ecommerce: ['material-purple', 'golden', 'sunset', 'electric'],
dashboard: ['corporate-blue', 'slate', 'midnight', 'accessibility'],
mobile: ['material-purple', 'material-blue', 'ocean', 'neon']
};
};
/**
* Get theme metadata
* @param themeId Theme identifier
* @returns Theme metadata or null
*/
export const getThemeMetadata = (themeId: string) => {
const theme = DEFAULT_THEMES[themeId];
if (!theme) return null;
const categories = getThemesByCategory();
const recommendations = getRecommendedThemes();
// Find which category this theme belongs to
let category = 'other';
for (const [cat, themes] of Object.entries(categories)) {
if (themes.includes(themeId)) {
category = cat;
break;
}
}
// Find recommended use cases
const useCases: string[] = [];
for (const [useCase, themes] of Object.entries(recommendations)) {
if (themes.includes(themeId)) {
useCases.push(useCase);
}
}
return {
...theme,
category,
useCases,
id: themeId
};
};

View File

@@ -0,0 +1,423 @@
/**
* ==========================================================================
* THEME STORAGE SERVICE
* ==========================================================================
* Handles persistence of themes and user preferences using localStorage
* Provides backup and import/export functionality
* ==========================================================================
*/
import { ThemeConfig, ThemeCollection } from '../models/hcl.models';
export class ThemeStorage {
private static readonly STORAGE_KEYS = {
CURRENT_THEME: 'hcl-studio-current-theme',
CURRENT_MODE: 'hcl-studio-current-mode',
CUSTOM_THEMES: 'hcl-studio-custom-themes',
USER_PREFERENCES: 'hcl-studio-preferences'
} as const;
// ==========================================================================
// CURRENT THEME PERSISTENCE
// ==========================================================================
/**
* Save current theme ID
* @param themeId Theme identifier
*/
static saveCurrentTheme(themeId: string): void {
try {
localStorage.setItem(this.STORAGE_KEYS.CURRENT_THEME, themeId);
} catch (error) {
console.warn('Failed to save current theme:', error);
}
}
/**
* Get current theme ID
* @returns Current theme ID or null
*/
static getCurrentTheme(): string | null {
try {
return localStorage.getItem(this.STORAGE_KEYS.CURRENT_THEME);
} catch (error) {
console.warn('Failed to get current theme:', error);
return null;
}
}
/**
* Save current mode (light/dark)
* @param mode Current mode
*/
static saveCurrentMode(mode: 'light' | 'dark'): void {
try {
localStorage.setItem(this.STORAGE_KEYS.CURRENT_MODE, mode);
} catch (error) {
console.warn('Failed to save current mode:', error);
}
}
/**
* Get current mode
* @returns Current mode or 'light' as default
*/
static getCurrentMode(): 'light' | 'dark' {
try {
const mode = localStorage.getItem(this.STORAGE_KEYS.CURRENT_MODE);
return mode === 'dark' ? 'dark' : 'light';
} catch (error) {
console.warn('Failed to get current mode:', error);
return 'light';
}
}
// ==========================================================================
// CUSTOM THEMES MANAGEMENT
// ==========================================================================
/**
* Save a custom theme
* @param themeConfig Theme configuration
*/
static saveCustomTheme(themeConfig: ThemeConfig): void {
try {
const customThemes = this.getCustomThemes();
customThemes[themeConfig.id] = {
...themeConfig,
createdAt: themeConfig.createdAt || new Date()
};
localStorage.setItem(this.STORAGE_KEYS.CUSTOM_THEMES, JSON.stringify(customThemes));
} catch (error) {
console.warn('Failed to save custom theme:', error);
}
}
/**
* Get all custom themes
* @returns Custom themes collection
*/
static getCustomThemes(): { [id: string]: ThemeConfig } {
try {
const stored = localStorage.getItem(this.STORAGE_KEYS.CUSTOM_THEMES);
return stored ? JSON.parse(stored) : {};
} catch (error) {
console.warn('Failed to get custom themes:', error);
return {};
}
}
/**
* Get specific custom theme
* @param themeId Theme ID
* @returns Theme configuration or null
*/
static getCustomTheme(themeId: string): ThemeConfig | null {
const customThemes = this.getCustomThemes();
return customThemes[themeId] || null;
}
/**
* Delete custom theme
* @param themeId Theme ID to delete
*/
static deleteCustomTheme(themeId: string): void {
try {
const customThemes = this.getCustomThemes();
delete customThemes[themeId];
localStorage.setItem(this.STORAGE_KEYS.CUSTOM_THEMES, JSON.stringify(customThemes));
} catch (error) {
console.warn('Failed to delete custom theme:', error);
}
}
/**
* Check if theme is custom (user-created)
* @param themeId Theme ID
* @returns True if theme is custom
*/
static isCustomTheme(themeId: string): boolean {
const customThemes = this.getCustomThemes();
return themeId in customThemes;
}
// ==========================================================================
// USER PREFERENCES
// ==========================================================================
/**
* Save user preferences
* @param preferences User preference object
*/
static savePreferences(preferences: any): void {
try {
localStorage.setItem(this.STORAGE_KEYS.USER_PREFERENCES, JSON.stringify(preferences));
} catch (error) {
console.warn('Failed to save preferences:', error);
}
}
/**
* Get user preferences
* @returns User preferences or default object
*/
static getPreferences(): any {
try {
const stored = localStorage.getItem(this.STORAGE_KEYS.USER_PREFERENCES);
return stored ? JSON.parse(stored) : {
autoMode: false,
transitionDuration: 300,
enableSystemTheme: true
};
} catch (error) {
console.warn('Failed to get preferences:', error);
return {
autoMode: false,
transitionDuration: 300,
enableSystemTheme: true
};
}
}
// ==========================================================================
// IMPORT/EXPORT FUNCTIONALITY
// ==========================================================================
/**
* Export theme configuration as JSON string
* @param themeId Theme ID to export
* @returns JSON string or null if theme not found
*/
static exportTheme(themeId: string): string | null {
const customTheme = this.getCustomTheme(themeId);
if (!customTheme) return null;
const exportData = {
version: '1.0',
exportDate: new Date().toISOString(),
theme: customTheme
};
return JSON.stringify(exportData, null, 2);
}
/**
* Import theme from JSON string
* @param jsonString JSON theme data
* @returns Imported theme config or null if invalid
*/
static importTheme(jsonString: string): ThemeConfig | null {
try {
const importData = JSON.parse(jsonString);
// Validate import data structure
if (!importData.theme || !importData.theme.id || !importData.theme.name || !importData.theme.colors) {
throw new Error('Invalid theme format');
}
const themeConfig: ThemeConfig = {
...importData.theme,
createdAt: new Date(),
description: importData.theme.description || 'Imported theme'
};
// Check for ID conflicts and rename if necessary
const existingThemes = this.getCustomThemes();
if (existingThemes[themeConfig.id]) {
themeConfig.id = this.generateUniqueId(themeConfig.id, existingThemes);
}
// Save imported theme
this.saveCustomTheme(themeConfig);
return themeConfig;
} catch (error) {
console.warn('Failed to import theme:', error);
return null;
}
}
/**
* Export all custom themes
* @returns JSON string with all custom themes
*/
static exportAllThemes(): string {
const customThemes = this.getCustomThemes();
const exportData = {
version: '1.0',
exportDate: new Date().toISOString(),
themes: customThemes
};
return JSON.stringify(exportData, null, 2);
}
/**
* Import multiple themes from JSON string
* @param jsonString JSON data with multiple themes
* @returns Array of imported theme IDs
*/
static importMultipleThemes(jsonString: string): string[] {
try {
const importData = JSON.parse(jsonString);
const importedIds: string[] = [];
if (!importData.themes) {
throw new Error('Invalid multi-theme format');
}
const existingThemes = this.getCustomThemes();
Object.values(importData.themes).forEach((theme: any) => {
if (theme.id && theme.name && theme.colors) {
let themeConfig: ThemeConfig = {
...theme,
createdAt: new Date(),
description: theme.description || 'Imported theme'
};
// Handle ID conflicts
if (existingThemes[themeConfig.id]) {
themeConfig.id = this.generateUniqueId(themeConfig.id, existingThemes);
}
this.saveCustomTheme(themeConfig);
importedIds.push(themeConfig.id);
}
});
return importedIds;
} catch (error) {
console.warn('Failed to import multiple themes:', error);
return [];
}
}
// ==========================================================================
// UTILITY METHODS
// ==========================================================================
/**
* Generate unique theme ID to avoid conflicts
* @param baseId Base theme ID
* @param existingThemes Existing themes to check against
* @returns Unique theme ID
*/
private static generateUniqueId(baseId: string, existingThemes: { [id: string]: any }): string {
let counter = 1;
let newId = `${baseId}-${counter}`;
while (existingThemes[newId]) {
counter++;
newId = `${baseId}-${counter}`;
}
return newId;
}
/**
* Clear all stored data (reset to defaults)
*/
static clearAllData(): void {
try {
Object.values(this.STORAGE_KEYS).forEach(key => {
localStorage.removeItem(key);
});
} catch (error) {
console.warn('Failed to clear storage data:', error);
}
}
/**
* Get storage usage information
* @returns Storage usage stats
*/
static getStorageInfo(): { customThemes: number; totalSize: number; isAvailable: boolean } {
try {
const customThemes = Object.keys(this.getCustomThemes()).length;
let totalSize = 0;
Object.values(this.STORAGE_KEYS).forEach(key => {
const item = localStorage.getItem(key);
if (item) {
totalSize += item.length;
}
});
return {
customThemes,
totalSize,
isAvailable: true
};
} catch (error) {
return {
customThemes: 0,
totalSize: 0,
isAvailable: false
};
}
}
/**
* Check if localStorage is available
* @returns True if localStorage is available
*/
static isStorageAvailable(): boolean {
try {
const test = 'hcl-studio-test';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch {
return false;
}
}
/**
* Create backup of all theme data
* @returns Backup data object
*/
static createBackup(): any {
return {
version: '1.0',
backupDate: new Date().toISOString(),
currentTheme: this.getCurrentTheme(),
currentMode: this.getCurrentMode(),
customThemes: this.getCustomThemes(),
preferences: this.getPreferences()
};
}
/**
* Restore from backup data
* @param backupData Backup data object
* @returns True if restore was successful
*/
static restoreFromBackup(backupData: any): boolean {
try {
if (backupData.currentTheme) {
this.saveCurrentTheme(backupData.currentTheme);
}
if (backupData.currentMode) {
this.saveCurrentMode(backupData.currentMode);
}
if (backupData.customThemes) {
localStorage.setItem(this.STORAGE_KEYS.CUSTOM_THEMES, JSON.stringify(backupData.customThemes));
}
if (backupData.preferences) {
this.savePreferences(backupData.preferences);
}
return true;
} catch (error) {
console.warn('Failed to restore from backup:', error);
return false;
}
}
}

View File

@@ -0,0 +1,35 @@
/**
* ==========================================================================
* HCL STUDIO - PUBLIC API
* ==========================================================================
* Export all public interfaces, services, and utilities
* ==========================================================================
*/
// ==========================================================================
// MAIN SERVICE
// ==========================================================================
export * from './lib/hcl-studio.service';
// ==========================================================================
// CORE FUNCTIONALITY
// ==========================================================================
export * from './lib/core/hcl-converter';
export * from './lib/core/palette-generator';
// ==========================================================================
// THEME MANAGEMENT
// ==========================================================================
export * from './lib/themes/css-variable-manager';
export * from './lib/themes/theme-storage';
export * from './lib/themes/theme-presets';
// ==========================================================================
// TYPE DEFINITIONS
// ==========================================================================
export * from './lib/models/hcl.models';
// ==========================================================================
// COMPONENTS (if any are created)
// ==========================================================================
export * from './lib/hcl-studio.component';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,164 @@
# UI Animations
A comprehensive CSS animation library with Angular services and directives for programmatic control.
## Features
- **Pure CSS animations** - Framework agnostic, works everywhere
- **Angular integration** - Services and directives for programmatic control
- **Comprehensive collection** - Entrance, exit, emphasis, and transition animations
- **Design system integration** - Uses semantic motion tokens for consistent timing and easing
- **Utility classes** - Animation timing, delays, and control utilities
- **SCSS mixins** - Create custom animations easily
## Installation
```bash
npm install ui-animations
```
## Usage
### Import Styles
Add to your global styles or component styles:
```scss
// Complete animation library
@use 'ui-animations/src/styles' as animations;
// Or import specific modules
@use 'ui-animations/src/styles/animations/entrances';
@use 'ui-animations/src/styles/animations/emphasis';
@use 'ui-animations/src/styles/utilities/animation-utilities';
```
### CSS Classes
Simply add animation classes to your HTML elements:
```html
<!-- Entrance animations -->
<div class="animate-fade-in">Fade in animation</div>
<div class="animate-slide-in-up animation-delay-200">Delayed slide up</div>
<!-- Emphasis animations -->
<button class="animate-bounce">Bouncing button</button>
<div class="animate-pulse animation-infinite">Pulsing element</div>
<!-- Exit animations -->
<div class="animate-fade-out">Fade out animation</div>
```
### Angular Service
```typescript
import { UiAnimationsService } from 'ui-animations';
constructor(private animationService: UiAnimationsService) {}
// Animate an element
animateElement() {
const element = document.getElementById('my-element');
this.animationService.animateOnce(element, 'animate-bounce');
}
// Animate with callback
animateWithCallback() {
const element = document.getElementById('my-element');
this.animationService.animate(element, 'animate-fade-out', () => {
console.log('Animation completed!');
});
}
```
### Angular Directive
```typescript
import { AnimateDirective } from 'ui-animations';
@Component({
imports: [AnimateDirective]
})
```
```html
<!-- Immediate animation -->
<div uiAnimate="animate-fade-in-up">Auto animates on load</div>
<!-- Hover trigger -->
<div uiAnimate="animate-bounce" [animationTrigger]="'hover'">
Hover to animate
</div>
<!-- Click trigger with single use -->
<button uiAnimate="animate-tada" [animationTrigger]="'click'" [animationOnce]="true">
Click me!
</button>
```
## Animation Categories
### Entrances
- `animate-fade-in` - Basic fade in
- `animate-fade-in-up/down/left/right` - Fade with direction
- `animate-slide-in-up/down` - Slide animations
- `animate-zoom-in` - Scale up animation
- `animate-rotate-in` - Rotate entrance
### Exits
- `animate-fade-out` - Basic fade out
- `animate-fade-out-up/down/left/right` - Fade with direction
- `animate-slide-out-up/down` - Slide animations
- `animate-zoom-out` - Scale down animation
- `animate-rotate-out` - Rotate exit
### Emphasis
- `animate-bounce` - Bouncing effect
- `animate-shake` - Horizontal shake
- `animate-pulse` - Scaling pulse
- `animate-wobble` - Wobble motion
- `animate-tada` - Celebration animation
- `animate-heartbeat` - Heart beat effect
### Utilities
- `animation-delay-100/200/300/500/1000` - Animation delays
- `animation-duration-fast/normal/slow/slower` - Duration control
- `animation-infinite/once/twice` - Iteration control
- `animation-paused/running` - Playback control
## SCSS Mixins
Create custom animations using provided mixins with semantic tokens:
```scss
@use 'ui-animations/src/styles/mixins/animation-mixins' as mixins;
@use 'ui-design-system/src/styles/semantic/motion' as motion;
.my-custom-animation {
@include mixins.animate(myKeyframes, motion.$semantic-motion-duration-normal, motion.$semantic-motion-easing-ease-out);
}
.hover-effect {
@include mixins.hover-animation(transform, scale(1.1), motion.$semantic-motion-duration-fast);
}
.loading-spinner {
@include mixins.loading-animation(40px, #007bff);
}
```
## Design System Integration
All animations now use semantic motion tokens from the design system:
- **Durations**: `$semantic-motion-duration-fast`, `$semantic-motion-duration-normal`, `$semantic-motion-duration-slow`, etc.
- **Easing**: `$semantic-motion-easing-ease-out`, `$semantic-motion-easing-spring`, `$semantic-motion-easing-bounce`, etc.
- **Spacing**: Animation distances use semantic spacing tokens for consistency
- **Timing**: Delays use semantic transition timing tokens
This ensures animations are consistent with your design system's motion principles.
## Demo
Visit the demo application to see all animations in action and experiment with different options.

View File

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

View File

@@ -0,0 +1,14 @@
{
"name": "ui-animations",
"version": "0.0.1",
"description": "CSS animation library with Angular services and directives for programmatic control",
"keywords": ["angular", "animations", "css", "ui", "transitions"],
"peerDependencies": {
"@angular/common": "^19.2.0",
"@angular/core": "^19.2.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}

View File

@@ -0,0 +1,66 @@
import { Directive, ElementRef, Input, OnInit, OnDestroy } from '@angular/core';
import { UiAnimationsService } from './ui-animations.service';
@Directive({
selector: '[uiAnimate]',
standalone: true
})
export class AnimateDirective implements OnInit, OnDestroy {
@Input('uiAnimate') animationClass: string = '';
@Input() animationTrigger: 'immediate' | 'hover' | 'click' | 'manual' = 'immediate';
@Input() animationOnce: boolean = false;
private hoverHandler?: () => void;
private clickHandler?: () => void;
constructor(
private elementRef: ElementRef<HTMLElement>,
private animationService: UiAnimationsService
) {}
ngOnInit() {
if (this.animationTrigger === 'immediate') {
this.animate();
} else if (this.animationTrigger === 'hover') {
this.setupHoverTrigger();
} else if (this.animationTrigger === 'click') {
this.setupClickTrigger();
}
}
ngOnDestroy() {
if (this.hoverHandler) {
this.elementRef.nativeElement.removeEventListener('mouseenter', this.hoverHandler);
}
if (this.clickHandler) {
this.elementRef.nativeElement.removeEventListener('click', this.clickHandler);
}
}
private setupHoverTrigger() {
this.hoverHandler = () => this.animate();
this.elementRef.nativeElement.addEventListener('mouseenter', this.hoverHandler);
}
private setupClickTrigger() {
this.clickHandler = () => this.animate();
this.elementRef.nativeElement.addEventListener('click', this.clickHandler);
}
private animate() {
if (!this.animationClass) return;
if (this.animationOnce) {
this.animationService.animateOnce(this.elementRef.nativeElement, this.animationClass);
} else {
this.animationService.animate(this.elementRef.nativeElement, this.animationClass);
}
}
/**
* Manually trigger animation (for manual trigger mode)
*/
public trigger() {
this.animate();
}
}

View File

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

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class UiAnimationsService {
/**
* Add animation class to element with optional completion callback
*/
animate(element: HTMLElement, animationClass: string, onComplete?: () => void): Promise<void> {
return new Promise((resolve) => {
const handleAnimationEnd = () => {
element.removeEventListener('animationend', handleAnimationEnd);
if (onComplete) onComplete();
resolve();
};
element.addEventListener('animationend', handleAnimationEnd);
element.classList.add(animationClass);
});
}
/**
* Remove animation class from element
*/
removeAnimation(element: HTMLElement, animationClass: string): void {
element.classList.remove(animationClass);
}
/**
* Add animation class and automatically remove it after completion
*/
animateOnce(element: HTMLElement, animationClass: string): Promise<void> {
return this.animate(element, animationClass, () => {
this.removeAnimation(element, animationClass);
});
}
/**
* Check if element has animation class
*/
hasAnimation(element: HTMLElement, animationClass: string): boolean {
return element.classList.contains(animationClass);
}
}

View File

@@ -0,0 +1,6 @@
/*
* Public API Surface of ui-animations
*/
export * from './lib/ui-animations.service';
export * from './lib/animate.directive';

View File

@@ -0,0 +1,142 @@
// Emphasis Animations
// Attention-grabbing animations for highlighting elements
// Bounce
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
transform: translateY(0);
}
40%, 43% {
transform: translateY(-24px);
}
70% {
transform: translateY(-8px);
}
90% {
transform: translateY(-4px);
}
}
.animate-bounce {
animation: bounce 0.6s ease-in-out;
}
// Shake
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
.animate-shake {
animation: shake 0.6s ease-in-out;
}
// Pulse
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.animate-pulse {
animation: pulse 0.6s ease-in-out infinite;
}
// Flash
@keyframes flash {
0%, 50%, 100% { opacity: 1; }
25%, 75% { opacity: 0; }
}
.animate-flash {
animation: flash 0.6s ease-in-out;
}
// Wobble
@keyframes wobble {
0% { transform: translateX(0%); }
15% { transform: translateX(-25%) rotate(-5deg); }
30% { transform: translateX(20%) rotate(3deg); }
45% { transform: translateX(-15%) rotate(-3deg); }
60% { transform: translateX(10%) rotate(2deg); }
75% { transform: translateX(-5%) rotate(-1deg); }
100% { transform: translateX(0%); }
}
.animate-wobble {
animation: wobble 0.6s ease-in-out;
}
// Swing
@keyframes swing {
20% { transform: rotate(15deg); }
40% { transform: rotate(-10deg); }
60% { transform: rotate(5deg); }
80% { transform: rotate(-5deg); }
100% { transform: rotate(0deg); }
}
.animate-swing {
animation: swing 0.6s ease-in-out;
transform-origin: top center;
}
// Rubber Band
@keyframes rubberBand {
0% { transform: scaleX(1); }
30% { transform: scaleX(1.25) scaleY(0.75); }
40% { transform: scaleX(0.75) scaleY(1.25); }
50% { transform: scaleX(1.15) scaleY(0.85); }
65% { transform: scaleX(0.95) scaleY(1.05); }
75% { transform: scaleX(1.05) scaleY(0.95); }
100% { transform: scaleX(1) scaleY(1); }
}
.animate-rubber-band {
animation: rubberBand 0.6s ease-in-out;
}
// Tada
@keyframes tada {
0% { transform: scaleX(1) scaleY(1); }
10%, 20% { transform: scaleX(0.9) scaleY(0.9) rotate(-3deg); }
30%, 50%, 70%, 90% { transform: scaleX(1.1) scaleY(1.1) rotate(3deg); }
40%, 60%, 80% { transform: scaleX(1.1) scaleY(1.1) rotate(-3deg); }
100% { transform: scaleX(1) scaleY(1) rotate(0); }
}
.animate-tada {
animation: tada 0.6s ease-in-out;
}
// Heartbeat
@keyframes heartbeat {
0% { transform: scale(1); }
14% { transform: scale(1.3); }
28% { transform: scale(1); }
42% { transform: scale(1.3); }
70% { transform: scale(1); }
}
.animate-heartbeat {
animation: heartbeat 0.6s ease-in-out infinite;
}
// Jello
@keyframes jello {
11.1% { transform: skewX(-12.5deg) skewY(-12.5deg); }
22.2% { transform: skewX(6.25deg) skewY(6.25deg); }
33.3% { transform: skewX(-3.125deg) skewY(-3.125deg); }
44.4% { transform: skewX(1.5625deg) skewY(1.5625deg); }
55.5% { transform: skewX(-0.78125deg) skewY(-0.78125deg); }
66.6% { transform: skewX(0.390625deg) skewY(0.390625deg); }
77.7% { transform: skewX(-0.1953125deg) skewY(-0.1953125deg); }
88.8% { transform: skewX(0.09765625deg) skewY(0.09765625deg); }
100% { transform: skewX(0) skewY(0); }
}
.animate-jello {
animation: jello 0.6s ease-in-out;
transform-origin: center;
}

View File

@@ -0,0 +1,151 @@
// Entrance Animations
// Elements appearing/entering the view
// Fade In
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
// Fade In Up
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out;
}
// Fade In Down
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-down {
animation: fadeInDown 0.3s ease-out;
}
// Fade In Left
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in-left {
animation: fadeInLeft 0.3s ease-out;
}
// Fade In Right
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in-right {
animation: fadeInRight 0.3s ease-out;
}
// Slide In Up
@keyframes slideInUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.animate-slide-in-up {
animation: slideInUp 0.3s ease-out;
}
// Slide In Down
@keyframes slideInDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
.animate-slide-in-down {
animation: slideInDown 0.3s ease-out;
}
// Zoom In
@keyframes zoomIn {
from {
opacity: 0;
transform: scale(0.3);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-zoom-in {
animation: zoomIn 0.3s ease-out;
}
// Scale Up
@keyframes scaleUp {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.animate-scale-up {
animation: scaleUp 0.15s ease-out;
}
// Rotate In
@keyframes rotateIn {
from {
opacity: 0;
transform: rotate(-200deg);
}
to {
opacity: 1;
transform: rotate(0);
}
}
.animate-rotate-in {
animation: rotateIn 0.3s ease-out;
}

View File

@@ -0,0 +1,151 @@
// Exit Animations
// Elements disappearing/leaving the view
// Fade Out
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
.animate-fade-out {
animation: fadeOut 0.3s ease-in;
}
// Fade Out Up
@keyframes fadeOutUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-24px);
}
}
.animate-fade-out-up {
animation: fadeOutUp 0.3s ease-in;
}
// Fade Out Down
@keyframes fadeOutDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(24px);
}
}
.animate-fade-out-down {
animation: fadeOutDown 0.3s ease-in;
}
// Fade Out Left
@keyframes fadeOutLeft {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-24px);
}
}
.animate-fade-out-left {
animation: fadeOutLeft 0.3s ease-in;
}
// Fade Out Right
@keyframes fadeOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(24px);
}
}
.animate-fade-out-right {
animation: fadeOutRight 0.3s ease-in;
}
// Slide Out Up
@keyframes slideOutUp {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
.animate-slide-out-up {
animation: slideOutUp 0.3s ease-in;
}
// Slide Out Down
@keyframes slideOutDown {
from {
transform: translateY(0);
}
to {
transform: translateY(100%);
}
}
.animate-slide-out-down {
animation: slideOutDown 0.3s ease-in;
}
// Zoom Out
@keyframes zoomOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.3);
}
}
.animate-zoom-out {
animation: zoomOut 0.3s ease-in;
}
// Scale Down
@keyframes scaleDown {
from {
transform: scale(1);
}
to {
transform: scale(0);
}
}
.animate-scale-down {
animation: scaleDown 0.15s ease-in;
}
// Rotate Out
@keyframes rotateOut {
from {
opacity: 1;
transform: rotate(0);
}
to {
opacity: 0;
transform: rotate(200deg);
}
}
.animate-rotate-out {
animation: rotateOut 0.3s ease-in;
}

Some files were not shown because too many files have changed in this diff Show More