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:
@@ -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
|
||||
}
|
||||
})
|
||||
)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './font-manager-demo.component';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<p>hcl-studio-demo works!</p>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%)' }
|
||||
];
|
||||
}
|
||||
@@ -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><ui-select
|
||||
label="Choose a country"
|
||||
placeholder="Select a country"
|
||||
[options]="countryOptions"
|
||||
[(ngModel)]="selectedCountry"
|
||||
(valueChange)="onCountryChange($event)">
|
||||
</ui-select></code></pre>
|
||||
|
||||
<h4>With Helper Text:</h4>
|
||||
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><ui-select
|
||||
label="Framework"
|
||||
placeholder="Select framework"
|
||||
helperText="Choose your preferred frontend framework"
|
||||
size="large"
|
||||
variant="filled"
|
||||
[options]="frameworks"
|
||||
[(ngModel)]="selectedFramework">
|
||||
</ui-select></code></pre>
|
||||
|
||||
<h4>Required with Error State:</h4>
|
||||
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><ui-select
|
||||
label="Required field"
|
||||
[required]="true"
|
||||
errorText="Please select an option"
|
||||
[options]="options"
|
||||
[(ngModel)]="selectedValue">
|
||||
</ui-select></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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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><ui-textarea
|
||||
label="Message"
|
||||
placeholder="Enter your message..."
|
||||
[(ngModel)]="message"
|
||||
(textareaChange)="onMessageChange($event)">
|
||||
</ui-textarea></code></pre>
|
||||
|
||||
<h4>With Character Limit:</h4>
|
||||
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><ui-textarea
|
||||
label="Tweet"
|
||||
placeholder="What's happening?"
|
||||
[maxLength]="280"
|
||||
[showCharacterCount]="true"
|
||||
size="lg"
|
||||
[(ngModel)]="tweetText">
|
||||
</ui-textarea></code></pre>
|
||||
|
||||
<h4>Auto-resize with Validation:</h4>
|
||||
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><ui-textarea
|
||||
label="Feedback"
|
||||
[required]="true"
|
||||
[autoResize]="true"
|
||||
[clearable]="true"
|
||||
state="error"
|
||||
errorMessage="Please provide feedback"
|
||||
[prefixIcon]="faComment"
|
||||
[(ngModel)]="feedback">
|
||||
</ui-textarea></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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
63
projects/hcl-studio/README.md
Normal file
63
projects/hcl-studio/README.md
Normal 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.
|
||||
7
projects/hcl-studio/ng-package.json
Normal file
7
projects/hcl-studio/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/hcl-studio",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
12
projects/hcl-studio/package.json
Normal file
12
projects/hcl-studio/package.json
Normal 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
|
||||
}
|
||||
324
projects/hcl-studio/src/lib/core/hcl-converter.ts
Normal file
324
projects/hcl-studio/src/lib/core/hcl-converter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
353
projects/hcl-studio/src/lib/core/palette-generator.ts
Normal file
353
projects/hcl-studio/src/lib/core/palette-generator.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
23
projects/hcl-studio/src/lib/hcl-studio.component.spec.ts
Normal file
23
projects/hcl-studio/src/lib/hcl-studio.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
15
projects/hcl-studio/src/lib/hcl-studio.component.ts
Normal file
15
projects/hcl-studio/src/lib/hcl-studio.component.ts
Normal 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 {
|
||||
|
||||
}
|
||||
16
projects/hcl-studio/src/lib/hcl-studio.service.spec.ts
Normal file
16
projects/hcl-studio/src/lib/hcl-studio.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
512
projects/hcl-studio/src/lib/hcl-studio.service.ts
Normal file
512
projects/hcl-studio/src/lib/hcl-studio.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
239
projects/hcl-studio/src/lib/models/hcl.models.ts
Normal file
239
projects/hcl-studio/src/lib/models/hcl.models.ts
Normal 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';
|
||||
320
projects/hcl-studio/src/lib/themes/css-variable-manager.ts
Normal file
320
projects/hcl-studio/src/lib/themes/css-variable-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
357
projects/hcl-studio/src/lib/themes/theme-presets.ts
Normal file
357
projects/hcl-studio/src/lib/themes/theme-presets.ts
Normal 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
|
||||
};
|
||||
};
|
||||
423
projects/hcl-studio/src/lib/themes/theme-storage.ts
Normal file
423
projects/hcl-studio/src/lib/themes/theme-storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
projects/hcl-studio/src/public-api.ts
Normal file
35
projects/hcl-studio/src/public-api.ts
Normal 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';
|
||||
15
projects/hcl-studio/tsconfig.lib.json
Normal file
15
projects/hcl-studio/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
11
projects/hcl-studio/tsconfig.lib.prod.json
Normal file
11
projects/hcl-studio/tsconfig.lib.prod.json
Normal file
@@ -0,0 +1,11 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
15
projects/hcl-studio/tsconfig.spec.json
Normal file
15
projects/hcl-studio/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
364
projects/ui-accessibility/README.md
Normal file
364
projects/ui-accessibility/README.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# UI Accessibility
|
||||
|
||||
A comprehensive Angular accessibility library that enhances your existing components with WCAG 2.1 Level AA compliant features while using semantic tokens from your design system.
|
||||
|
||||
## Features
|
||||
|
||||
- **🎯 Focus Management** - Advanced focus trapping, restoration, and monitoring
|
||||
- **⌨️ Keyboard Navigation** - Arrow keys, roving tabindex, custom shortcuts
|
||||
- **📢 Screen Reader Support** - Live announcements, ARIA enhancements
|
||||
- **🎨 Design System Integration** - Uses semantic motion, color, and spacing tokens
|
||||
- **♿ High Contrast & Reduced Motion** - Automatic detection and adaptation
|
||||
- **🔧 Zero Rewrites Required** - Enhance existing components with directives
|
||||
- **🧪 Developer Experience** - Debug modes, warnings, and comprehensive logging
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ui-accessibility
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Import the Module
|
||||
|
||||
```typescript
|
||||
import { UiAccessibilityModule } from 'ui-accessibility';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
UiAccessibilityModule.forRoot({
|
||||
// Optional configuration
|
||||
skipLinks: { enabled: true },
|
||||
keyboard: { enableArrowNavigation: true },
|
||||
development: { warnings: true }
|
||||
})
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
### 2. Import SCSS Utilities
|
||||
|
||||
```scss
|
||||
// Import accessibility utilities (uses your semantic tokens)
|
||||
@use 'ui-accessibility/src/lib/utilities/a11y-utilities';
|
||||
```
|
||||
|
||||
### 3. Add Skip Links
|
||||
|
||||
```html
|
||||
<!-- Add to your app.component.html -->
|
||||
<ui-skip-links></ui-skip-links>
|
||||
```
|
||||
|
||||
## Core Features
|
||||
|
||||
### Focus Trap Directive
|
||||
|
||||
Trap focus within containers like modals and drawers:
|
||||
|
||||
```html
|
||||
<!-- Before: Basic modal -->
|
||||
<ui-modal [open]="isOpen">
|
||||
<h2>Settings</h2>
|
||||
<input type="text" placeholder="Name">
|
||||
<button>Save</button>
|
||||
</ui-modal>
|
||||
|
||||
<!-- After: Focus-trapped modal -->
|
||||
<ui-modal [open]="isOpen" uiFocusTrap>
|
||||
<h2>Settings</h2>
|
||||
<input type="text" placeholder="Name">
|
||||
<button>Save</button>
|
||||
</ui-modal>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Automatic focus on first element
|
||||
- Tab wrapping within container
|
||||
- Focus restoration on close
|
||||
- Uses semantic motion tokens for transitions
|
||||
|
||||
### Arrow Navigation Directive
|
||||
|
||||
Add keyboard navigation to lists, menus, and grids:
|
||||
|
||||
```html
|
||||
<!-- Before: Basic menu -->
|
||||
<ui-menu>
|
||||
<ui-menu-item>Profile</ui-menu-item>
|
||||
<ui-menu-item>Settings</ui-menu-item>
|
||||
<ui-menu-item>Logout</ui-menu-item>
|
||||
</ui-menu>
|
||||
|
||||
<!-- After: Keyboard navigable menu -->
|
||||
<ui-menu uiArrowNavigation="vertical">
|
||||
<ui-menu-item tabindex="0">Profile</ui-menu-item>
|
||||
<ui-menu-item tabindex="-1">Settings</ui-menu-item>
|
||||
<ui-menu-item tabindex="-1">Logout</ui-menu-item>
|
||||
</ui-menu>
|
||||
```
|
||||
|
||||
**Navigation Options:**
|
||||
- `vertical` - Up/Down arrows
|
||||
- `horizontal` - Left/Right arrows
|
||||
- `both` - All arrow keys
|
||||
- `grid` - 2D navigation with column support
|
||||
|
||||
### Live Announcer Service
|
||||
|
||||
Announce dynamic content changes to screen readers:
|
||||
|
||||
```typescript
|
||||
import { LiveAnnouncerService } from 'ui-accessibility';
|
||||
|
||||
constructor(private announcer: LiveAnnouncerService) {}
|
||||
|
||||
// Announce with different politeness levels
|
||||
onItemAdded() {
|
||||
this.announcer.announce('Item added to cart', 'polite');
|
||||
}
|
||||
|
||||
onError() {
|
||||
this.announcer.announce('Error: Please fix the required fields', 'assertive');
|
||||
}
|
||||
|
||||
// Announce a sequence of messages
|
||||
onProcessComplete() {
|
||||
this.announcer.announceSequence([
|
||||
'Processing started',
|
||||
'Validating data',
|
||||
'Processing complete'
|
||||
], 'polite', 1500);
|
||||
}
|
||||
```
|
||||
|
||||
### Screen Reader Components
|
||||
|
||||
Hide content visually while keeping it accessible:
|
||||
|
||||
```html
|
||||
<!-- Screen reader only text -->
|
||||
<ui-screen-reader-only>
|
||||
Additional context for screen readers
|
||||
</ui-screen-reader-only>
|
||||
|
||||
<!-- Focusable hidden text (becomes visible on focus) -->
|
||||
<ui-screen-reader-only type="focusable">
|
||||
Press Enter to activate
|
||||
</ui-screen-reader-only>
|
||||
|
||||
<!-- Status messages -->
|
||||
<ui-screen-reader-only type="status" statusType="error" [visible]="hasError">
|
||||
Please correct the errors below
|
||||
</ui-screen-reader-only>
|
||||
```
|
||||
|
||||
### Keyboard Manager Service
|
||||
|
||||
Register global and element-specific keyboard shortcuts:
|
||||
|
||||
```typescript
|
||||
import { KeyboardManagerService } from 'ui-accessibility';
|
||||
|
||||
constructor(private keyboard: KeyboardManagerService) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Global shortcut
|
||||
this.keyboard.registerGlobalShortcut('save', {
|
||||
key: 's',
|
||||
ctrlKey: true,
|
||||
description: 'Save document',
|
||||
handler: () => this.save()
|
||||
});
|
||||
|
||||
// Element-specific shortcut
|
||||
const element = this.elementRef.nativeElement;
|
||||
this.keyboard.registerElementShortcut('close', element, {
|
||||
key: 'Escape',
|
||||
description: 'Close dialog',
|
||||
handler: () => this.close()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Design System Integration
|
||||
|
||||
All features use semantic tokens from your design system:
|
||||
|
||||
### Focus Indicators
|
||||
|
||||
```scss
|
||||
// Automatic integration with your focus tokens
|
||||
.my-component:focus-visible {
|
||||
outline: $semantic-border-focus-width solid $semantic-color-focus;
|
||||
box-shadow: $semantic-shadow-input-focus;
|
||||
transition: outline-color $semantic-motion-duration-fast;
|
||||
}
|
||||
```
|
||||
|
||||
### Skip Links Styling
|
||||
|
||||
Uses your semantic spacing, colors, and typography:
|
||||
|
||||
```scss
|
||||
.skip-link {
|
||||
padding: $semantic-spacing-component-padding-y $semantic-spacing-component-padding-x;
|
||||
background: $semantic-color-surface-primary;
|
||||
color: $semantic-color-text-primary;
|
||||
border-color: $semantic-color-border-focus;
|
||||
}
|
||||
```
|
||||
|
||||
### Motion & Animations
|
||||
|
||||
Respects reduced motion preferences:
|
||||
|
||||
```typescript
|
||||
// Service automatically detects preference
|
||||
const duration = this.highContrast.getMotionDuration('300ms', '0.01s');
|
||||
|
||||
// SCSS utilities adapt automatically
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in-a11y {
|
||||
animation-duration: 0.01s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Full Configuration Example
|
||||
|
||||
```typescript
|
||||
UiAccessibilityModule.forRoot({
|
||||
announcer: {
|
||||
defaultPoliteness: 'polite',
|
||||
defaultDuration: 4000,
|
||||
enabled: true
|
||||
},
|
||||
focusManagement: {
|
||||
trapFocus: true,
|
||||
restoreFocus: true,
|
||||
focusVisibleEnabled: true
|
||||
},
|
||||
keyboard: {
|
||||
enableShortcuts: true,
|
||||
enableArrowNavigation: true,
|
||||
customShortcuts: [
|
||||
{ key: '/', ctrlKey: true, description: 'Search' }
|
||||
]
|
||||
},
|
||||
skipLinks: {
|
||||
enabled: true,
|
||||
position: 'top',
|
||||
links: [
|
||||
{ href: '#main', text: 'Skip to main content' },
|
||||
{ href: '#nav', text: 'Skip to navigation' },
|
||||
{ href: '#search', text: 'Skip to search' }
|
||||
]
|
||||
},
|
||||
accessibility: {
|
||||
respectReducedMotion: true,
|
||||
respectHighContrast: true,
|
||||
injectAccessibilityStyles: true,
|
||||
addBodyClasses: true
|
||||
},
|
||||
development: {
|
||||
warnings: true,
|
||||
logging: true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### High Contrast & Reduced Motion
|
||||
|
||||
Automatic detection and CSS class application:
|
||||
|
||||
```html
|
||||
<!-- Body classes added automatically -->
|
||||
<body class="prefers-reduced-motion prefers-high-contrast">
|
||||
|
||||
<!-- Your CSS can respond -->
|
||||
<style>
|
||||
.prefers-reduced-motion * {
|
||||
animation-duration: 0.01s !important;
|
||||
}
|
||||
|
||||
.prefers-high-contrast .card {
|
||||
border: 1px solid ButtonText;
|
||||
background: ButtonFace;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Available Directives
|
||||
|
||||
| Directive | Purpose | Usage |
|
||||
|-----------|---------|--------|
|
||||
| `uiFocusTrap` | Trap focus within container | `<div uiFocusTrap>` |
|
||||
| `uiArrowNavigation` | Arrow key navigation | `<ul uiArrowNavigation="vertical">` |
|
||||
|
||||
## Available Services
|
||||
|
||||
| Service | Purpose | Key Methods |
|
||||
|---------|---------|-------------|
|
||||
| `LiveAnnouncerService` | Screen reader announcements | `announce()`, `announceSequence()` |
|
||||
| `FocusMonitorService` | Focus origin tracking | `monitor()`, `focusVia()` |
|
||||
| `KeyboardManagerService` | Keyboard shortcuts | `registerGlobalShortcut()` |
|
||||
| `HighContrastService` | Accessibility preferences | `getCurrentPreferences()` |
|
||||
| `A11yConfigService` | Configuration management | `getConfig()`, `isEnabled()` |
|
||||
|
||||
## SCSS Utilities
|
||||
|
||||
```scss
|
||||
// Import specific utilities
|
||||
@use 'ui-accessibility/src/lib/utilities/focus-visible';
|
||||
@use 'ui-accessibility/src/lib/utilities/screen-reader';
|
||||
|
||||
// Use utility classes
|
||||
.my-element {
|
||||
@extend .focus-ring; // Adds focus ring on :focus-visible
|
||||
@extend .touch-target; // Ensures minimum touch target size
|
||||
}
|
||||
|
||||
// Screen reader utilities
|
||||
.sr-instructions {
|
||||
@extend .sr-only; // Visually hidden, screen reader accessible
|
||||
}
|
||||
|
||||
.status-text {
|
||||
@extend .sr-only-focusable; // Hidden until focused
|
||||
}
|
||||
```
|
||||
|
||||
## WCAG 2.1 Compliance
|
||||
|
||||
This library helps achieve Level AA compliance:
|
||||
|
||||
- ✅ **1.3.1** - Info and Relationships (ARIA attributes)
|
||||
- ✅ **1.4.3** - Contrast (High contrast mode support)
|
||||
- ✅ **1.4.13** - Content on Hover/Focus (Proper focus management)
|
||||
- ✅ **2.1.1** - Keyboard (Full keyboard navigation)
|
||||
- ✅ **2.1.2** - No Keyboard Trap (Proper focus trapping)
|
||||
- ✅ **2.4.1** - Bypass Blocks (Skip links)
|
||||
- ✅ **2.4.3** - Focus Order (Logical tab sequence)
|
||||
- ✅ **2.4.7** - Focus Visible (Enhanced focus indicators)
|
||||
- ✅ **3.2.1** - On Focus (Predictable focus behavior)
|
||||
- ✅ **4.1.3** - Status Messages (Live announcements)
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome 88+
|
||||
- Firefox 85+
|
||||
- Safari 14+
|
||||
- Edge 88+
|
||||
|
||||
## Demo
|
||||
|
||||
Visit `/accessibility` in your demo application to see interactive examples of all features.
|
||||
|
||||
## Contributing
|
||||
|
||||
This library integrates seamlessly with your existing component architecture and design system tokens, requiring no rewrites of existing components.
|
||||
7
projects/ui-accessibility/ng-package.json
Normal file
7
projects/ui-accessibility/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/ui-accessibility",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
15
projects/ui-accessibility/package.json
Normal file
15
projects/ui-accessibility/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "ui-accessibility",
|
||||
"version": "0.0.1",
|
||||
"description": "Comprehensive accessibility utilities for Angular applications with semantic token integration",
|
||||
"keywords": ["angular", "accessibility", "a11y", "wcag", "focus-management", "screen-reader", "semantic-tokens"],
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"rxjs": "~7.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"sideEffects": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './screen-reader-only.component';
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-screen-reader-only',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<span
|
||||
[class]="getClasses()"
|
||||
[attr.aria-live]="ariaLive"
|
||||
[attr.aria-atomic]="ariaAtomic"
|
||||
[attr.role]="role"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
`,
|
||||
styles: [`
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sr-only-focusable {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sr-only-focusable:focus,
|
||||
.sr-only-focusable:active {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
background: var(--a11y-skip-link-bg, #fff);
|
||||
color: var(--a11y-skip-link-color, #000);
|
||||
border: 1px solid var(--a11y-skip-link-border, #007bff);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.status-message.status-visible {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 4px 0;
|
||||
background: var(--a11y-skip-link-bg, #fff);
|
||||
border-left: 4px solid var(--a11y-focus-color, #007bff);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.status-message.status-error {
|
||||
border-left-color: var(--a11y-error-color, #dc3545);
|
||||
}
|
||||
|
||||
.status-message.status-success {
|
||||
border-left-color: var(--a11y-success-color, #28a745);
|
||||
}
|
||||
|
||||
.status-message.status-warning {
|
||||
border-left-color: var(--a11y-warning-color, #ffc107);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-contrast: high) {
|
||||
.sr-only-focusable:focus,
|
||||
.sr-only-focusable:active {
|
||||
background: ButtonFace;
|
||||
color: ButtonText;
|
||||
border-color: ButtonText;
|
||||
}
|
||||
|
||||
.status-message.status-visible {
|
||||
background: ButtonFace;
|
||||
color: ButtonText;
|
||||
border-color: ButtonText;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ScreenReaderOnlyComponent {
|
||||
@Input() type: 'default' | 'focusable' | 'status' | 'instructions' = 'default';
|
||||
@Input() statusType?: 'error' | 'success' | 'warning';
|
||||
@Input() visible = false;
|
||||
@Input() ariaLive?: 'polite' | 'assertive' | 'off';
|
||||
@Input() ariaAtomic?: 'true' | 'false';
|
||||
@Input() role?: string;
|
||||
|
||||
/**
|
||||
* Get CSS classes based on component configuration
|
||||
*/
|
||||
getClasses(): string {
|
||||
const classes: string[] = [];
|
||||
|
||||
switch (this.type) {
|
||||
case 'focusable':
|
||||
classes.push('sr-only-focusable');
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
classes.push('status-message');
|
||||
if (this.visible) {
|
||||
classes.push('status-visible');
|
||||
}
|
||||
if (this.statusType) {
|
||||
classes.push(`status-${this.statusType}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'instructions':
|
||||
classes.push('instructions');
|
||||
break;
|
||||
|
||||
default:
|
||||
classes.push('sr-only');
|
||||
break;
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './skip-links.component';
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { A11yConfigService } from '../../services/a11y-config';
|
||||
|
||||
export interface SkipLink {
|
||||
href: string;
|
||||
text: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ui-skip-links',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<nav
|
||||
class="skip-links"
|
||||
[class.skip-links--after-header]="position === 'after-header'"
|
||||
role="navigation"
|
||||
aria-label="Skip navigation links"
|
||||
>
|
||||
@for (link of skipLinks; track link.href) {
|
||||
<a
|
||||
class="skip-link"
|
||||
[href]="link.href"
|
||||
[attr.aria-label]="link.ariaLabel || link.text"
|
||||
(click)="handleSkipLinkClick($event, link.href)"
|
||||
>
|
||||
{{ link.text }}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
styles: [`
|
||||
.skip-links {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.skip-links--after-header {
|
||||
position: relative;
|
||||
display: block;
|
||||
background: var(--a11y-skip-link-bg, #fff);
|
||||
border-bottom: 1px solid var(--a11y-skip-link-border, #ccc);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: var(--a11y-skip-link-bg, #fff);
|
||||
color: var(--a11y-skip-link-color, #000);
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--a11y-skip-link-border, #007bff);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.15s ease-out;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 6px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
outline: 2px solid var(--a11y-focus-color, #007bff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skip-link:nth-child(2):focus {
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.skip-link:nth-child(3):focus {
|
||||
top: 94px;
|
||||
}
|
||||
|
||||
.skip-link:nth-child(4):focus {
|
||||
top: 138px;
|
||||
}
|
||||
|
||||
.skip-links--after-header .skip-link {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
transition: background-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
.skip-links--after-header .skip-link:hover,
|
||||
.skip-links--after-header .skip-link:focus {
|
||||
background-color: var(--a11y-focus-ring-color, rgba(0, 123, 255, 0.1));
|
||||
}
|
||||
|
||||
@media (prefers-contrast: high) {
|
||||
.skip-link {
|
||||
background: ButtonFace;
|
||||
color: ButtonText;
|
||||
border-color: ButtonText;
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skip-link {
|
||||
transition-duration: 0.01s;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SkipLinksComponent implements OnInit {
|
||||
private config = inject(A11yConfigService);
|
||||
|
||||
@Input() skipLinks: SkipLink[] = [];
|
||||
@Input() position: 'top' | 'after-header' = 'top';
|
||||
@Input() showOnFocus = true;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Use default links from config if none provided
|
||||
if (this.skipLinks.length === 0) {
|
||||
const configLinks = this.config.getSection('skipLinks').links;
|
||||
if (configLinks) {
|
||||
this.skipLinks = configLinks.map(link => ({
|
||||
href: link.href,
|
||||
text: link.text,
|
||||
ariaLabel: `Skip to ${link.text.toLowerCase()}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Use position from config if not explicitly set
|
||||
if (this.position === 'top') {
|
||||
const configPosition = this.config.getSection('skipLinks').position;
|
||||
if (configPosition) {
|
||||
this.position = configPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle skip link click to ensure proper focus management
|
||||
*/
|
||||
handleSkipLinkClick(event: Event, href: string): void {
|
||||
event.preventDefault();
|
||||
|
||||
const targetId = href.replace('#', '');
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
// Set tabindex to make element focusable if it's not naturally focusable
|
||||
const originalTabIndex = targetElement.getAttribute('tabindex');
|
||||
if (!this.isFocusable(targetElement)) {
|
||||
targetElement.setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
// Focus the target element
|
||||
targetElement.focus();
|
||||
|
||||
// Scroll to the element
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
// Restore original tabindex after focus
|
||||
if (originalTabIndex === null && !this.isFocusable(targetElement)) {
|
||||
setTimeout(() => {
|
||||
targetElement.removeAttribute('tabindex');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
this.config.log('Skip link activated', { href, targetId });
|
||||
} else {
|
||||
this.config.warn('Skip link target not found', { href, targetId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is naturally focusable
|
||||
*/
|
||||
private isFocusable(element: HTMLElement): boolean {
|
||||
const focusableSelectors = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'textarea',
|
||||
'input[type="text"]',
|
||||
'input[type="radio"]',
|
||||
'input[type="checkbox"]',
|
||||
'select',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
];
|
||||
|
||||
return focusableSelectors.some(selector => element.matches(selector)) ||
|
||||
element.getAttribute('contenteditable') === 'true';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
EventEmitter,
|
||||
NgZone,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { fromEvent, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { A11yConfigService } from '../../services/a11y-config';
|
||||
|
||||
export type NavigationDirection = 'horizontal' | 'vertical' | 'both' | 'grid';
|
||||
|
||||
export interface NavigationEvent {
|
||||
direction: 'up' | 'down' | 'left' | 'right' | 'home' | 'end';
|
||||
currentIndex: number;
|
||||
nextIndex: number;
|
||||
event: KeyboardEvent;
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[uiArrowNavigation]',
|
||||
standalone: true,
|
||||
exportAs: 'uiArrowNavigation'
|
||||
})
|
||||
export class ArrowNavigationDirective implements OnInit, OnDestroy {
|
||||
private elementRef = inject(ElementRef);
|
||||
private ngZone = inject(NgZone);
|
||||
private config = inject(A11yConfigService);
|
||||
|
||||
@Input('uiArrowNavigation') direction: NavigationDirection = 'vertical';
|
||||
@Input() wrap = true;
|
||||
@Input() skipDisabled = true;
|
||||
@Input() itemSelector = '[tabindex]:not([tabindex="-1"]), button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
|
||||
@Input() gridColumns?: number;
|
||||
|
||||
@Output() navigationChange = new EventEmitter<NavigationEvent>();
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private navigableItems: HTMLElement[] = [];
|
||||
private currentIndex = 0;
|
||||
private isGridNavigation = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.config.isEnabled('keyboard.enableArrowNavigation')) {
|
||||
this.setupNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific index
|
||||
*/
|
||||
navigateToIndex(index: number): void {
|
||||
this.updateNavigableItems();
|
||||
|
||||
if (index >= 0 && index < this.navigableItems.length) {
|
||||
this.currentIndex = index;
|
||||
this.focusCurrentItem();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the first item
|
||||
*/
|
||||
navigateToFirst(): void {
|
||||
this.navigateToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the last item
|
||||
*/
|
||||
navigateToLast(): void {
|
||||
this.updateNavigableItems();
|
||||
this.navigateToIndex(this.navigableItems.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current navigation index
|
||||
*/
|
||||
getCurrentIndex(): number {
|
||||
return this.currentIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of navigable items
|
||||
*/
|
||||
getItemCount(): number {
|
||||
this.updateNavigableItems();
|
||||
return this.navigableItems.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up navigation listeners
|
||||
*/
|
||||
private setupNavigation(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
this.isGridNavigation = this.direction === 'grid';
|
||||
this.updateNavigableItems();
|
||||
this.setupKeyboardListeners();
|
||||
this.setupFocusTracking();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of navigable items
|
||||
*/
|
||||
private updateNavigableItems(): void {
|
||||
const container = this.elementRef.nativeElement;
|
||||
const items = Array.from(
|
||||
container.querySelectorAll(this.itemSelector)
|
||||
) as HTMLElement[];
|
||||
|
||||
this.navigableItems = this.skipDisabled
|
||||
? items.filter((item: HTMLElement) => !this.isDisabled(item))
|
||||
: items;
|
||||
|
||||
// Update current index if current item is no longer available
|
||||
if (this.currentIndex >= this.navigableItems.length) {
|
||||
this.currentIndex = Math.max(0, this.navigableItems.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up keyboard event listeners
|
||||
*/
|
||||
private setupKeyboardListeners(): void {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
fromEvent<KeyboardEvent>(this.elementRef.nativeElement, 'keydown').pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(event => {
|
||||
this.handleKeyDown(event);
|
||||
});
|
||||
|
||||
// Update items when DOM changes
|
||||
const observer = new MutationObserver(() => {
|
||||
this.updateNavigableItems();
|
||||
});
|
||||
|
||||
observer.observe(this.elementRef.nativeElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['disabled', 'tabindex', 'hidden']
|
||||
});
|
||||
|
||||
this.destroy$.subscribe(() => observer.disconnect());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up focus tracking to maintain current index
|
||||
*/
|
||||
private setupFocusTracking(): void {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
fromEvent(this.elementRef.nativeElement, 'focusin').pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((event) => {
|
||||
const focusEvent = event as FocusEvent;
|
||||
const target = focusEvent.target as HTMLElement;
|
||||
const index = this.navigableItems.indexOf(target);
|
||||
if (index >= 0) {
|
||||
this.currentIndex = index;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation
|
||||
*/
|
||||
private handleKeyDown(event: KeyboardEvent): void {
|
||||
if (this.navigableItems.length === 0) return;
|
||||
|
||||
const { key } = event;
|
||||
let nextIndex = this.currentIndex;
|
||||
let direction: NavigationEvent['direction'] | null = null;
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
direction = 'up';
|
||||
nextIndex = this.isGridNavigation
|
||||
? this.getGridIndex('up')
|
||||
: this.getVerticalIndex('up');
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
direction = 'down';
|
||||
nextIndex = this.isGridNavigation
|
||||
? this.getGridIndex('down')
|
||||
: this.getVerticalIndex('down');
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
direction = 'left';
|
||||
nextIndex = this.isGridNavigation
|
||||
? this.getGridIndex('left')
|
||||
: this.getHorizontalIndex('left');
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
direction = 'right';
|
||||
nextIndex = this.isGridNavigation
|
||||
? this.getGridIndex('right')
|
||||
: this.getHorizontalIndex('right');
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
direction = 'home';
|
||||
nextIndex = 0;
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
direction = 'end';
|
||||
nextIndex = this.navigableItems.length - 1;
|
||||
break;
|
||||
|
||||
default:
|
||||
return; // Don't handle other keys
|
||||
}
|
||||
|
||||
if (direction && this.shouldHandleNavigation(key)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const navigationEvent: NavigationEvent = {
|
||||
direction,
|
||||
currentIndex: this.currentIndex,
|
||||
nextIndex,
|
||||
event
|
||||
};
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.navigationChange.emit(navigationEvent);
|
||||
});
|
||||
|
||||
if (nextIndex !== this.currentIndex && nextIndex >= 0 && nextIndex < this.navigableItems.length) {
|
||||
this.currentIndex = nextIndex;
|
||||
this.focusCurrentItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should handle this navigation key
|
||||
*/
|
||||
private shouldHandleNavigation(key: string): boolean {
|
||||
switch (this.direction) {
|
||||
case 'horizontal':
|
||||
return ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key);
|
||||
case 'vertical':
|
||||
return ['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key);
|
||||
case 'both':
|
||||
case 'grid':
|
||||
return ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next index for vertical navigation
|
||||
*/
|
||||
private getVerticalIndex(direction: 'up' | 'down'): number {
|
||||
const delta = direction === 'up' ? -1 : 1;
|
||||
let nextIndex = this.currentIndex + delta;
|
||||
|
||||
if (this.wrap) {
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = this.navigableItems.length - 1;
|
||||
} else if (nextIndex >= this.navigableItems.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
} else {
|
||||
nextIndex = Math.max(0, Math.min(this.navigableItems.length - 1, nextIndex));
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next index for horizontal navigation
|
||||
*/
|
||||
private getHorizontalIndex(direction: 'left' | 'right'): number {
|
||||
const delta = direction === 'left' ? -1 : 1;
|
||||
let nextIndex = this.currentIndex + delta;
|
||||
|
||||
if (this.wrap) {
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = this.navigableItems.length - 1;
|
||||
} else if (nextIndex >= this.navigableItems.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
} else {
|
||||
nextIndex = Math.max(0, Math.min(this.navigableItems.length - 1, nextIndex));
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next index for grid navigation
|
||||
*/
|
||||
private getGridIndex(direction: 'up' | 'down' | 'left' | 'right'): number {
|
||||
if (!this.gridColumns) {
|
||||
// Fallback to regular navigation if no columns specified
|
||||
return direction === 'up' || direction === 'down'
|
||||
? this.getVerticalIndex(direction as 'up' | 'down')
|
||||
: this.getHorizontalIndex(direction as 'left' | 'right');
|
||||
}
|
||||
|
||||
const currentRow = Math.floor(this.currentIndex / this.gridColumns);
|
||||
const currentCol = this.currentIndex % this.gridColumns;
|
||||
let nextIndex = this.currentIndex;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
if (currentRow > 0) {
|
||||
nextIndex = (currentRow - 1) * this.gridColumns + currentCol;
|
||||
} else if (this.wrap) {
|
||||
const lastRow = Math.floor((this.navigableItems.length - 1) / this.gridColumns);
|
||||
nextIndex = Math.min(lastRow * this.gridColumns + currentCol, this.navigableItems.length - 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'down':
|
||||
const nextRow = currentRow + 1;
|
||||
const nextRowIndex = nextRow * this.gridColumns + currentCol;
|
||||
if (nextRowIndex < this.navigableItems.length) {
|
||||
nextIndex = nextRowIndex;
|
||||
} else if (this.wrap) {
|
||||
nextIndex = currentCol < this.navigableItems.length ? currentCol : 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'left':
|
||||
if (currentCol > 0) {
|
||||
nextIndex = this.currentIndex - 1;
|
||||
} else if (this.wrap) {
|
||||
const lastInRow = Math.min((currentRow + 1) * this.gridColumns - 1, this.navigableItems.length - 1);
|
||||
nextIndex = lastInRow;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'right':
|
||||
if (currentCol < this.gridColumns - 1 && this.currentIndex + 1 < this.navigableItems.length) {
|
||||
nextIndex = this.currentIndex + 1;
|
||||
} else if (this.wrap) {
|
||||
nextIndex = currentRow * this.gridColumns;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the current item
|
||||
*/
|
||||
private focusCurrentItem(): void {
|
||||
if (this.navigableItems[this.currentIndex]) {
|
||||
this.navigableItems[this.currentIndex].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item is disabled
|
||||
*/
|
||||
private isDisabled(item: HTMLElement): boolean {
|
||||
return item.hasAttribute('disabled') ||
|
||||
item.getAttribute('aria-disabled') === 'true' ||
|
||||
item.tabIndex === -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './arrow-navigation.directive';
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
NgZone,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { fromEvent, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { A11yConfigService } from '../../services/a11y-config';
|
||||
|
||||
@Directive({
|
||||
selector: '[uiFocusTrap]',
|
||||
standalone: true,
|
||||
exportAs: 'uiFocusTrap'
|
||||
})
|
||||
export class FocusTrapDirective implements OnInit, OnDestroy {
|
||||
private elementRef = inject(ElementRef);
|
||||
private ngZone = inject(NgZone);
|
||||
private config = inject(A11yConfigService);
|
||||
|
||||
@Input('uiFocusTrap') enabled = true;
|
||||
@Input() autoFocus = true;
|
||||
@Input() restoreFocus = true;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private previouslyFocusedElement: HTMLElement | null = null;
|
||||
private focusableElements: HTMLElement[] = [];
|
||||
private firstFocusable: HTMLElement | null = null;
|
||||
private lastFocusable: HTMLElement | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.enabled) {
|
||||
this.setupFocusTrap();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
if (this.restoreFocus && this.previouslyFocusedElement) {
|
||||
this.previouslyFocusedElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the focus trap
|
||||
*/
|
||||
enable(): void {
|
||||
this.enabled = true;
|
||||
this.setupFocusTrap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the focus trap
|
||||
*/
|
||||
disable(): void {
|
||||
this.enabled = false;
|
||||
this.destroy$.next();
|
||||
|
||||
if (this.restoreFocus && this.previouslyFocusedElement) {
|
||||
this.previouslyFocusedElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually focus the first focusable element
|
||||
*/
|
||||
focusFirst(): void {
|
||||
this.updateFocusableElements();
|
||||
if (this.firstFocusable) {
|
||||
this.firstFocusable.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually focus the last focusable element
|
||||
*/
|
||||
focusLast(): void {
|
||||
this.updateFocusableElements();
|
||||
if (this.lastFocusable) {
|
||||
this.lastFocusable.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the focus trap
|
||||
*/
|
||||
private setupFocusTrap(): void {
|
||||
if (!this.enabled || typeof document === 'undefined') return;
|
||||
|
||||
// Store the currently focused element to restore later
|
||||
this.previouslyFocusedElement = document.activeElement as HTMLElement;
|
||||
|
||||
// Set up the trap
|
||||
setTimeout(() => {
|
||||
this.updateFocusableElements();
|
||||
this.setupKeyboardListeners();
|
||||
|
||||
if (this.autoFocus) {
|
||||
this.focusFirst();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of focusable elements
|
||||
*/
|
||||
private updateFocusableElements(): void {
|
||||
const container = this.elementRef.nativeElement;
|
||||
|
||||
const focusableSelectors = [
|
||||
'a[href]:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"]):not([disabled])',
|
||||
'[contenteditable="true"]:not([disabled])',
|
||||
'audio[controls]:not([disabled])',
|
||||
'video[controls]:not([disabled])',
|
||||
'iframe:not([disabled])',
|
||||
'object:not([disabled])',
|
||||
'embed:not([disabled])',
|
||||
'area[href]:not([disabled])',
|
||||
'summary:not([disabled])'
|
||||
].join(', ');
|
||||
|
||||
const elements = container.querySelectorAll(focusableSelectors);
|
||||
this.focusableElements = Array.from(elements)
|
||||
.filter((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
// Filter out hidden elements
|
||||
return (
|
||||
htmlElement.offsetWidth > 0 ||
|
||||
htmlElement.offsetHeight > 0 ||
|
||||
htmlElement.getClientRects().length > 0
|
||||
);
|
||||
}) as HTMLElement[];
|
||||
|
||||
this.firstFocusable = this.focusableElements[0] || null;
|
||||
this.lastFocusable = this.focusableElements[this.focusableElements.length - 1] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up keyboard event listeners
|
||||
*/
|
||||
private setupKeyboardListeners(): void {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
fromEvent<KeyboardEvent>(document, 'keydown').pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(event => {
|
||||
if (event.key === 'Tab') {
|
||||
this.handleTabKey(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Update focusable elements when DOM changes
|
||||
const observer = new MutationObserver(() => {
|
||||
this.updateFocusableElements();
|
||||
});
|
||||
|
||||
observer.observe(this.elementRef.nativeElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['tabindex', 'disabled', 'hidden']
|
||||
});
|
||||
|
||||
// Clean up observer
|
||||
this.destroy$.subscribe(() => observer.disconnect());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Tab key press
|
||||
*/
|
||||
private handleTabKey(event: KeyboardEvent): void {
|
||||
if (!this.enabled || this.focusableElements.length === 0) return;
|
||||
|
||||
// Check if focus is within our trap
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isWithinTrap = this.elementRef.nativeElement.contains(activeElement);
|
||||
|
||||
if (!isWithinTrap) {
|
||||
// Focus escaped the trap, bring it back
|
||||
event.preventDefault();
|
||||
this.focusFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle normal tab navigation within trap
|
||||
if (event.shiftKey) {
|
||||
// Shift + Tab (backward)
|
||||
if (activeElement === this.firstFocusable) {
|
||||
event.preventDefault();
|
||||
this.focusLast();
|
||||
}
|
||||
} else {
|
||||
// Tab (forward)
|
||||
if (activeElement === this.lastFocusable) {
|
||||
event.preventDefault();
|
||||
this.focusFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './focus-trap.directive';
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
|
||||
export interface A11yConfiguration {
|
||||
// Live announcer settings
|
||||
announcer?: {
|
||||
defaultPoliteness?: 'polite' | 'assertive';
|
||||
defaultDuration?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
// Focus management settings
|
||||
focusManagement?: {
|
||||
trapFocus?: boolean;
|
||||
restoreFocus?: boolean;
|
||||
focusVisibleEnabled?: boolean;
|
||||
focusOriginDetection?: boolean;
|
||||
};
|
||||
|
||||
// Keyboard navigation settings
|
||||
keyboard?: {
|
||||
enableShortcuts?: boolean;
|
||||
enableArrowNavigation?: boolean;
|
||||
enableRovingTabindex?: boolean;
|
||||
customShortcuts?: Array<{
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
description?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Skip links settings
|
||||
skipLinks?: {
|
||||
enabled?: boolean;
|
||||
position?: 'top' | 'after-header';
|
||||
showOnFocus?: boolean;
|
||||
links?: Array<{
|
||||
href: string;
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Auto-enhancement settings
|
||||
autoEnhancement?: {
|
||||
enabled?: boolean;
|
||||
enhanceComponents?: string[]; // Component selectors to enhance
|
||||
addAriaAttributes?: boolean;
|
||||
addKeyboardSupport?: boolean;
|
||||
addFocusManagement?: boolean;
|
||||
};
|
||||
|
||||
// High contrast and reduced motion
|
||||
accessibility?: {
|
||||
respectReducedMotion?: boolean;
|
||||
respectHighContrast?: boolean;
|
||||
injectAccessibilityStyles?: boolean;
|
||||
addBodyClasses?: boolean;
|
||||
};
|
||||
|
||||
// Development settings
|
||||
development?: {
|
||||
warnings?: boolean;
|
||||
logging?: boolean;
|
||||
accessibilityAuditing?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_A11Y_CONFIG: Required<A11yConfiguration> = {
|
||||
announcer: {
|
||||
defaultPoliteness: 'polite',
|
||||
defaultDuration: 4000,
|
||||
enabled: true
|
||||
},
|
||||
focusManagement: {
|
||||
trapFocus: true,
|
||||
restoreFocus: true,
|
||||
focusVisibleEnabled: true,
|
||||
focusOriginDetection: true
|
||||
},
|
||||
keyboard: {
|
||||
enableShortcuts: true,
|
||||
enableArrowNavigation: true,
|
||||
enableRovingTabindex: true,
|
||||
customShortcuts: []
|
||||
},
|
||||
skipLinks: {
|
||||
enabled: true,
|
||||
position: 'top',
|
||||
showOnFocus: true,
|
||||
links: [
|
||||
{ href: '#main', text: 'Skip to main content' },
|
||||
{ href: '#navigation', text: 'Skip to navigation' }
|
||||
]
|
||||
},
|
||||
autoEnhancement: {
|
||||
enabled: false,
|
||||
enhanceComponents: [],
|
||||
addAriaAttributes: true,
|
||||
addKeyboardSupport: true,
|
||||
addFocusManagement: true
|
||||
},
|
||||
accessibility: {
|
||||
respectReducedMotion: true,
|
||||
respectHighContrast: true,
|
||||
injectAccessibilityStyles: true,
|
||||
addBodyClasses: true
|
||||
},
|
||||
development: {
|
||||
warnings: true,
|
||||
logging: false,
|
||||
accessibilityAuditing: false
|
||||
}
|
||||
};
|
||||
|
||||
export const A11Y_CONFIG = new InjectionToken<A11yConfiguration>('A11Y_CONFIG');
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class A11yConfigService {
|
||||
private config: Required<A11yConfiguration>;
|
||||
|
||||
constructor() {
|
||||
this.config = { ...DEFAULT_A11Y_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the configuration
|
||||
*/
|
||||
updateConfig(config: Partial<A11yConfiguration>): void {
|
||||
this.config = this.mergeConfig(this.config, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current configuration
|
||||
*/
|
||||
getConfig(): Required<A11yConfiguration> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration section
|
||||
*/
|
||||
getSection<K extends keyof A11yConfiguration>(section: K): Required<A11yConfiguration>[K] {
|
||||
return this.config[section];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is enabled
|
||||
*/
|
||||
isEnabled(feature: string): boolean {
|
||||
const parts = feature.split('.');
|
||||
let current: any = this.config;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in current) {
|
||||
current = current[part];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return current === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message if logging is enabled
|
||||
*/
|
||||
log(message: string, ...args: any[]): void {
|
||||
if (this.config.development.logging) {
|
||||
console.log(`[A11y]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning if warnings are enabled
|
||||
*/
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (this.config.development.warnings) {
|
||||
console.warn(`[A11y Warning]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error
|
||||
*/
|
||||
error(message: string, ...args: any[]): void {
|
||||
console.error(`[A11y Error]`, message, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge configuration objects
|
||||
*/
|
||||
private mergeConfig(
|
||||
base: Required<A11yConfiguration>,
|
||||
override: Partial<A11yConfiguration>
|
||||
): Required<A11yConfiguration> {
|
||||
const result = { ...base };
|
||||
|
||||
for (const key in override) {
|
||||
const value = override[key as keyof A11yConfiguration];
|
||||
if (value !== undefined) {
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
|
||||
(result as any)[key] = this.mergeObjects(
|
||||
(result as any)[key] || {},
|
||||
value
|
||||
);
|
||||
} else {
|
||||
(result as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge objects
|
||||
*/
|
||||
private mergeObjects(base: any, override: any): any {
|
||||
const result = { ...base };
|
||||
|
||||
for (const key in override) {
|
||||
const value = override[key];
|
||||
if (value !== undefined) {
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
|
||||
result[key] = this.mergeObjects(result[key] || {}, value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './a11y-config.service';
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Injectable, NgZone, OnDestroy, ElementRef } from '@angular/core';
|
||||
import { Observable, Subject, fromEvent, merge } from 'rxjs';
|
||||
import { takeUntil, map, distinctUntilChanged } from 'rxjs/operators';
|
||||
|
||||
export type FocusOrigin = 'keyboard' | 'mouse' | 'touch' | 'program' | null;
|
||||
|
||||
export interface FocusMonitorOptions {
|
||||
detectSubtleFocusChanges?: boolean;
|
||||
checkChildren?: boolean;
|
||||
}
|
||||
|
||||
export interface FocusEvent {
|
||||
origin: FocusOrigin;
|
||||
event: Event | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FocusMonitorService implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private currentFocusOrigin: FocusOrigin = null;
|
||||
private lastFocusOrigin: FocusOrigin = null;
|
||||
private lastTouchTarget: EventTarget | null = null;
|
||||
|
||||
private monitoredElements = new Map<Element, {
|
||||
subject: Subject<FocusEvent>;
|
||||
unlisten: () => void;
|
||||
}>();
|
||||
|
||||
constructor(private ngZone: NgZone) {
|
||||
this.setupGlobalListeners();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
// Clean up all monitored elements
|
||||
this.monitoredElements.forEach((info) => info.unlisten());
|
||||
this.monitoredElements.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor focus changes on an element
|
||||
*/
|
||||
monitor(
|
||||
element: Element | ElementRef<Element>,
|
||||
options: FocusMonitorOptions = {}
|
||||
): Observable<FocusEvent> {
|
||||
const elementToMonitor = element instanceof ElementRef ? element.nativeElement : element;
|
||||
|
||||
if (this.monitoredElements.has(elementToMonitor)) {
|
||||
return this.monitoredElements.get(elementToMonitor)!.subject.asObservable();
|
||||
}
|
||||
|
||||
const subject = new Subject<FocusEvent>();
|
||||
|
||||
const focusHandler = (event: FocusEvent) => {
|
||||
subject.next(event);
|
||||
};
|
||||
|
||||
const blurHandler = () => {
|
||||
subject.next({ origin: null, event: null });
|
||||
};
|
||||
|
||||
// Set up event listeners
|
||||
const focusListener = this.ngZone.runOutsideAngular(() => {
|
||||
return fromEvent(elementToMonitor, 'focus', { capture: true }).pipe(
|
||||
takeUntil(this.destroy$),
|
||||
map((event: Event) => ({
|
||||
origin: this.currentFocusOrigin,
|
||||
event
|
||||
}))
|
||||
).subscribe(focusHandler);
|
||||
});
|
||||
|
||||
const blurListener = this.ngZone.runOutsideAngular(() => {
|
||||
return fromEvent(elementToMonitor, 'blur', { capture: true }).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(blurHandler);
|
||||
});
|
||||
|
||||
const unlisten = () => {
|
||||
focusListener.unsubscribe();
|
||||
blurListener.unsubscribe();
|
||||
subject.complete();
|
||||
};
|
||||
|
||||
this.monitoredElements.set(elementToMonitor, { subject, unlisten });
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
distinctUntilChanged((x, y) => x.origin === y.origin)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring focus changes on an element
|
||||
*/
|
||||
stopMonitoring(element: Element | ElementRef<Element>): void {
|
||||
const elementToStop = element instanceof ElementRef ? element.nativeElement : element;
|
||||
const info = this.monitoredElements.get(elementToStop);
|
||||
|
||||
if (info) {
|
||||
info.unlisten();
|
||||
this.monitoredElements.delete(elementToStop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus an element and mark it as programmatically focused
|
||||
*/
|
||||
focusVia(element: Element | ElementRef<Element>, origin: FocusOrigin): void {
|
||||
const elementToFocus = element instanceof ElementRef ? element.nativeElement : element;
|
||||
|
||||
this.setOrigin(origin);
|
||||
|
||||
if (typeof (elementToFocus as any).focus === 'function') {
|
||||
(elementToFocus as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current focus origin
|
||||
*/
|
||||
getCurrentFocusOrigin(): FocusOrigin {
|
||||
return this.currentFocusOrigin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the focus origin for the next focus event
|
||||
*/
|
||||
private setOrigin(origin: FocusOrigin): void {
|
||||
this.currentFocusOrigin = origin;
|
||||
this.lastFocusOrigin = origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up global event listeners to detect focus origin
|
||||
*/
|
||||
private setupGlobalListeners(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
// Mouse events
|
||||
merge(
|
||||
fromEvent(document, 'mousedown', { capture: true }),
|
||||
fromEvent(document, 'mouseup', { capture: true })
|
||||
).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(() => {
|
||||
this.setOrigin('mouse');
|
||||
});
|
||||
|
||||
// Keyboard events
|
||||
fromEvent(document, 'keydown', { capture: true }).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((event) => {
|
||||
const keyEvent = event as KeyboardEvent;
|
||||
// Only set keyboard origin for navigation keys
|
||||
if (this.isNavigationKey(keyEvent.key)) {
|
||||
this.setOrigin('keyboard');
|
||||
}
|
||||
});
|
||||
|
||||
// Touch events
|
||||
fromEvent(document, 'touchstart', { capture: true }).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((event) => {
|
||||
const touchEvent = event as TouchEvent;
|
||||
this.lastTouchTarget = touchEvent.target;
|
||||
this.setOrigin('touch');
|
||||
});
|
||||
|
||||
// Focus events that might be programmatic
|
||||
fromEvent(document, 'focus', { capture: true }).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((event) => {
|
||||
// If we don't have an origin, assume it's programmatic
|
||||
if (this.currentFocusOrigin === null) {
|
||||
this.setOrigin('program');
|
||||
}
|
||||
|
||||
// Reset origin after a short delay
|
||||
setTimeout(() => {
|
||||
this.currentFocusOrigin = null;
|
||||
}, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is a navigation key
|
||||
*/
|
||||
private isNavigationKey(key: string): boolean {
|
||||
const navigationKeys = [
|
||||
'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
|
||||
'Home', 'End', 'PageUp', 'PageDown', 'Enter', ' ', 'Escape'
|
||||
];
|
||||
return navigationKeys.includes(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './focus-monitor.service';
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||
import { Observable, fromEvent, Subject } from 'rxjs';
|
||||
import { takeUntil, map, startWith, distinctUntilChanged } from 'rxjs/operators';
|
||||
|
||||
export interface AccessibilityPreferences {
|
||||
prefersReducedMotion: boolean;
|
||||
prefersHighContrast: boolean;
|
||||
prefersDarkMode: boolean;
|
||||
prefersReducedTransparency: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class HighContrastService implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Reactive signals for preferences
|
||||
readonly prefersReducedMotion = signal(false);
|
||||
readonly prefersHighContrast = signal(false);
|
||||
readonly prefersDarkMode = signal(false);
|
||||
readonly prefersReducedTransparency = signal(false);
|
||||
|
||||
// Media query lists
|
||||
private reducedMotionQuery?: MediaQueryList;
|
||||
private highContrastQuery?: MediaQueryList;
|
||||
private darkModeQuery?: MediaQueryList;
|
||||
private reducedTransparencyQuery?: MediaQueryList;
|
||||
|
||||
constructor() {
|
||||
this.setupMediaQueries();
|
||||
this.updatePreferences();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accessibility preferences as an observable
|
||||
*/
|
||||
getPreferences$(): Observable<AccessibilityPreferences> {
|
||||
if (typeof window === 'undefined') {
|
||||
return new Observable(subscriber => {
|
||||
subscriber.next({
|
||||
prefersReducedMotion: false,
|
||||
prefersHighContrast: false,
|
||||
prefersDarkMode: false,
|
||||
prefersReducedTransparency: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return fromEvent(window, 'change').pipe(
|
||||
takeUntil(this.destroy$),
|
||||
startWith(null),
|
||||
map(() => this.getCurrentPreferences()),
|
||||
distinctUntilChanged((prev, curr) =>
|
||||
prev.prefersReducedMotion === curr.prefersReducedMotion &&
|
||||
prev.prefersHighContrast === curr.prefersHighContrast &&
|
||||
prev.prefersDarkMode === curr.prefersDarkMode &&
|
||||
prev.prefersReducedTransparency === curr.prefersReducedTransparency
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current accessibility preferences
|
||||
*/
|
||||
getCurrentPreferences(): AccessibilityPreferences {
|
||||
return {
|
||||
prefersReducedMotion: this.prefersReducedMotion(),
|
||||
prefersHighContrast: this.prefersHighContrast(),
|
||||
prefersDarkMode: this.prefersDarkMode(),
|
||||
prefersReducedTransparency: this.prefersReducedTransparency()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSS classes to document body based on preferences
|
||||
*/
|
||||
applyPreferencesToBody(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const body = document.body;
|
||||
|
||||
// Remove existing classes
|
||||
body.classList.remove(
|
||||
'prefers-reduced-motion',
|
||||
'prefers-high-contrast',
|
||||
'prefers-dark-mode',
|
||||
'prefers-reduced-transparency'
|
||||
);
|
||||
|
||||
// Add classes based on current preferences
|
||||
if (this.prefersReducedMotion()) {
|
||||
body.classList.add('prefers-reduced-motion');
|
||||
}
|
||||
|
||||
if (this.prefersHighContrast()) {
|
||||
body.classList.add('prefers-high-contrast');
|
||||
}
|
||||
|
||||
if (this.prefersDarkMode()) {
|
||||
body.classList.add('prefers-dark-mode');
|
||||
}
|
||||
|
||||
if (this.prefersReducedTransparency()) {
|
||||
body.classList.add('prefers-reduced-transparency');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS custom properties for motion duration based on reduced motion preference
|
||||
*/
|
||||
getMotionDuration(normalDuration: string, reducedDuration = '0.01s'): string {
|
||||
return this.prefersReducedMotion() ? reducedDuration : normalDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate animation timing based on reduced motion preference
|
||||
*/
|
||||
getAnimationConfig(normal: any, reduced: any = { duration: '0.01s', easing: 'linear' }): any {
|
||||
return this.prefersReducedMotion() ? reduced : normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user prefers reduced motion and return appropriate CSS
|
||||
*/
|
||||
getReducedMotionCSS(): string {
|
||||
if (this.prefersReducedMotion()) {
|
||||
return `
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup media query listeners
|
||||
*/
|
||||
private setupMediaQueries(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Reduced motion
|
||||
this.reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
this.reducedMotionQuery.addEventListener('change', () => {
|
||||
this.updatePreferences();
|
||||
this.applyPreferencesToBody();
|
||||
});
|
||||
|
||||
// High contrast
|
||||
this.highContrastQuery = window.matchMedia('(prefers-contrast: high)');
|
||||
this.highContrastQuery.addEventListener('change', () => {
|
||||
this.updatePreferences();
|
||||
this.applyPreferencesToBody();
|
||||
});
|
||||
|
||||
// Dark mode
|
||||
this.darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this.darkModeQuery.addEventListener('change', () => {
|
||||
this.updatePreferences();
|
||||
this.applyPreferencesToBody();
|
||||
});
|
||||
|
||||
// Reduced transparency
|
||||
this.reducedTransparencyQuery = window.matchMedia('(prefers-reduced-transparency: reduce)');
|
||||
this.reducedTransparencyQuery.addEventListener('change', () => {
|
||||
this.updatePreferences();
|
||||
this.applyPreferencesToBody();
|
||||
});
|
||||
|
||||
// Apply initial preferences
|
||||
this.applyPreferencesToBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update preference signals
|
||||
*/
|
||||
private updatePreferences(): void {
|
||||
this.prefersReducedMotion.set(this.reducedMotionQuery?.matches ?? false);
|
||||
this.prefersHighContrast.set(this.highContrastQuery?.matches ?? false);
|
||||
this.prefersDarkMode.set(this.darkModeQuery?.matches ?? false);
|
||||
this.prefersReducedTransparency.set(this.reducedTransparencyQuery?.matches ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a style element with reduced motion CSS
|
||||
*/
|
||||
injectReducedMotionStyles(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const existingStyle = document.getElementById('a11y-reduced-motion');
|
||||
if (existingStyle) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'a11y-reduced-motion';
|
||||
style.textContent = `
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-contrast: high) {
|
||||
* {
|
||||
text-shadow: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './high-contrast.service';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './keyboard-manager.service';
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Injectable, NgZone, OnDestroy } from '@angular/core';
|
||||
import { fromEvent, Subject } from 'rxjs';
|
||||
import { takeUntil, filter } from 'rxjs/operators';
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
description?: string;
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutRegistration {
|
||||
id: string;
|
||||
shortcut: KeyboardShortcut;
|
||||
element?: Element;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class KeyboardManagerService implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private shortcuts = new Map<string, KeyboardShortcutRegistration>();
|
||||
private globalShortcuts: KeyboardShortcutRegistration[] = [];
|
||||
private elementShortcuts = new Map<Element, KeyboardShortcutRegistration[]>();
|
||||
|
||||
constructor(private ngZone: NgZone) {
|
||||
this.setupGlobalListener();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.shortcuts.clear();
|
||||
this.globalShortcuts.length = 0;
|
||||
this.elementShortcuts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a global keyboard shortcut
|
||||
*/
|
||||
registerGlobalShortcut(
|
||||
id: string,
|
||||
shortcut: KeyboardShortcut,
|
||||
priority = 0
|
||||
): void {
|
||||
const registration: KeyboardShortcutRegistration = {
|
||||
id,
|
||||
shortcut,
|
||||
priority
|
||||
};
|
||||
|
||||
this.shortcuts.set(id, registration);
|
||||
this.globalShortcuts.push(registration);
|
||||
|
||||
// Sort by priority (higher priority first)
|
||||
this.globalShortcuts.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a keyboard shortcut for a specific element
|
||||
*/
|
||||
registerElementShortcut(
|
||||
id: string,
|
||||
element: Element,
|
||||
shortcut: KeyboardShortcut,
|
||||
priority = 0
|
||||
): void {
|
||||
const registration: KeyboardShortcutRegistration = {
|
||||
id,
|
||||
shortcut,
|
||||
element,
|
||||
priority
|
||||
};
|
||||
|
||||
this.shortcuts.set(id, registration);
|
||||
|
||||
if (!this.elementShortcuts.has(element)) {
|
||||
this.elementShortcuts.set(element, []);
|
||||
this.setupElementListener(element);
|
||||
}
|
||||
|
||||
const elementShortcuts = this.elementShortcuts.get(element)!;
|
||||
elementShortcuts.push(registration);
|
||||
|
||||
// Sort by priority (higher priority first)
|
||||
elementShortcuts.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a keyboard shortcut
|
||||
*/
|
||||
unregisterShortcut(id: string): void {
|
||||
const registration = this.shortcuts.get(id);
|
||||
if (!registration) return;
|
||||
|
||||
if (registration.element) {
|
||||
// Remove from element shortcuts
|
||||
const elementShortcuts = this.elementShortcuts.get(registration.element);
|
||||
if (elementShortcuts) {
|
||||
const index = elementShortcuts.findIndex(s => s.id === id);
|
||||
if (index >= 0) {
|
||||
elementShortcuts.splice(index, 1);
|
||||
}
|
||||
|
||||
// Clean up if no shortcuts left for this element
|
||||
if (elementShortcuts.length === 0) {
|
||||
this.elementShortcuts.delete(registration.element);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from global shortcuts
|
||||
const index = this.globalShortcuts.findIndex(s => s.id === id);
|
||||
if (index >= 0) {
|
||||
this.globalShortcuts.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.shortcuts.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered shortcuts
|
||||
*/
|
||||
getShortcuts(): KeyboardShortcutRegistration[] {
|
||||
return Array.from(this.shortcuts.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shortcuts for a specific element
|
||||
*/
|
||||
getElementShortcuts(element: Element): KeyboardShortcutRegistration[] {
|
||||
return this.elementShortcuts.get(element) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global shortcuts
|
||||
*/
|
||||
getGlobalShortcuts(): KeyboardShortcutRegistration[] {
|
||||
return [...this.globalShortcuts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister all keyboard shortcuts
|
||||
*/
|
||||
unregisterAll(): void {
|
||||
this.shortcuts.clear();
|
||||
this.globalShortcuts.length = 0;
|
||||
this.elementShortcuts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches a shortcut
|
||||
*/
|
||||
private matchesShortcut(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
|
||||
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!event.ctrlKey === !!shortcut.ctrlKey &&
|
||||
!!event.shiftKey === !!shortcut.shiftKey &&
|
||||
!!event.altKey === !!shortcut.altKey &&
|
||||
!!event.metaKey === !!shortcut.metaKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard event for shortcuts
|
||||
*/
|
||||
private handleKeyboardEvent(
|
||||
event: KeyboardEvent,
|
||||
shortcuts: KeyboardShortcutRegistration[]
|
||||
): boolean {
|
||||
for (const registration of shortcuts) {
|
||||
if (this.matchesShortcut(event, registration.shortcut)) {
|
||||
if (registration.shortcut.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (registration.shortcut.stopPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
registration.shortcut.handler(event);
|
||||
});
|
||||
|
||||
return true; // Shortcut handled
|
||||
}
|
||||
}
|
||||
|
||||
return false; // No shortcut matched
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up global keyboard event listener
|
||||
*/
|
||||
private setupGlobalListener(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
fromEvent<KeyboardEvent>(document, 'keydown').pipe(
|
||||
takeUntil(this.destroy$),
|
||||
filter(event => this.globalShortcuts.length > 0)
|
||||
).subscribe(event => {
|
||||
this.handleKeyboardEvent(event, this.globalShortcuts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up keyboard event listener for a specific element
|
||||
*/
|
||||
private setupElementListener(element: Element): void {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
fromEvent<KeyboardEvent>(element, 'keydown').pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(event => {
|
||||
const shortcuts = this.elementShortcuts.get(element) || [];
|
||||
this.handleKeyboardEvent(event, shortcuts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shortcut description for display purposes
|
||||
*/
|
||||
static getShortcutDescription(shortcut: KeyboardShortcut): string {
|
||||
if (shortcut.description) {
|
||||
return shortcut.description;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (shortcut.ctrlKey) parts.push('Ctrl');
|
||||
if (shortcut.altKey) parts.push('Alt');
|
||||
if (shortcut.shiftKey) parts.push('Shift');
|
||||
if (shortcut.metaKey) parts.push('Meta');
|
||||
|
||||
parts.push(shortcut.key.toUpperCase());
|
||||
|
||||
return parts.join(' + ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './live-announcer.service';
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
|
||||
export type AriaLivePoliteness = 'off' | 'polite' | 'assertive';
|
||||
|
||||
export interface LiveAnnouncementConfig {
|
||||
politeness?: AriaLivePoliteness;
|
||||
duration?: number;
|
||||
clearPrevious?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LiveAnnouncerService implements OnDestroy {
|
||||
private liveElement?: HTMLElement;
|
||||
private timeouts: Map<AriaLivePoliteness, any> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.createLiveElement();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.timeouts.forEach(timeout => clearTimeout(timeout));
|
||||
this.removeLiveElement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces a message to screen readers
|
||||
*/
|
||||
announce(
|
||||
message: string,
|
||||
politeness: AriaLivePoliteness = 'polite',
|
||||
duration?: number
|
||||
): void {
|
||||
this.announceWithConfig(message, {
|
||||
politeness,
|
||||
duration,
|
||||
clearPrevious: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces a message with full configuration options
|
||||
*/
|
||||
announceWithConfig(message: string, config: LiveAnnouncementConfig = {}): void {
|
||||
const {
|
||||
politeness = 'polite',
|
||||
duration = 4000,
|
||||
clearPrevious = true
|
||||
} = config;
|
||||
|
||||
if (!this.liveElement) {
|
||||
this.createLiveElement();
|
||||
}
|
||||
|
||||
if (!this.liveElement) return;
|
||||
|
||||
// Clear any existing timeout for this politeness level
|
||||
if (this.timeouts.has(politeness)) {
|
||||
clearTimeout(this.timeouts.get(politeness));
|
||||
}
|
||||
|
||||
// Clear previous message if requested
|
||||
if (clearPrevious) {
|
||||
this.liveElement.textContent = '';
|
||||
}
|
||||
|
||||
// Set the politeness level
|
||||
this.liveElement.setAttribute('aria-live', politeness);
|
||||
|
||||
// Announce the message
|
||||
setTimeout(() => {
|
||||
if (this.liveElement) {
|
||||
this.liveElement.textContent = message;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Clear the message after the specified duration
|
||||
if (duration > 0) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.liveElement) {
|
||||
this.liveElement.textContent = '';
|
||||
}
|
||||
this.timeouts.delete(politeness);
|
||||
}, duration);
|
||||
|
||||
this.timeouts.set(politeness, timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all current announcements
|
||||
*/
|
||||
clear(): void {
|
||||
if (this.liveElement) {
|
||||
this.liveElement.textContent = '';
|
||||
}
|
||||
this.timeouts.forEach(timeout => clearTimeout(timeout));
|
||||
this.timeouts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces multiple messages with delays between them
|
||||
*/
|
||||
announceSequence(
|
||||
messages: string[],
|
||||
politeness: AriaLivePoliteness = 'polite',
|
||||
delayBetween = 1000
|
||||
): void {
|
||||
messages.forEach((message, index) => {
|
||||
setTimeout(() => {
|
||||
this.announce(message, politeness);
|
||||
}, index * delayBetween);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the live region element
|
||||
*/
|
||||
private createLiveElement(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// Remove existing element if it exists
|
||||
this.removeLiveElement();
|
||||
|
||||
// Create new live region
|
||||
this.liveElement = document.createElement('div');
|
||||
this.liveElement.setAttribute('aria-live', 'polite');
|
||||
this.liveElement.setAttribute('aria-atomic', 'true');
|
||||
this.liveElement.setAttribute('id', 'a11y-live-announcer');
|
||||
|
||||
// Make it invisible but still accessible to screen readers
|
||||
this.liveElement.style.position = 'absolute';
|
||||
this.liveElement.style.left = '-10000px';
|
||||
this.liveElement.style.width = '1px';
|
||||
this.liveElement.style.height = '1px';
|
||||
this.liveElement.style.overflow = 'hidden';
|
||||
|
||||
document.body.appendChild(this.liveElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the live region element
|
||||
*/
|
||||
private removeLiveElement(): void {
|
||||
const existing = document.getElementById('a11y-live-announcer');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
this.liveElement = undefined;
|
||||
}
|
||||
}
|
||||
77
projects/ui-accessibility/src/lib/tokens/a11y-tokens.scss
Normal file
77
projects/ui-accessibility/src/lib/tokens/a11y-tokens.scss
Normal file
@@ -0,0 +1,77 @@
|
||||
// ==========================================================================
|
||||
// ACCESSIBILITY TOKENS
|
||||
// ==========================================================================
|
||||
// Import semantic tokens for accessibility features
|
||||
// ==========================================================================
|
||||
|
||||
// Import semantic tokens from design system
|
||||
@use 'ui-design-system/src/styles/semantic/colors' as colors;
|
||||
@use 'ui-design-system/src/styles/semantic/motion' as motion;
|
||||
@use 'ui-design-system/src/styles/semantic/spacing' as spacing;
|
||||
@use 'ui-design-system/src/styles/semantic/shadows' as shadows;
|
||||
@use 'ui-design-system/src/styles/semantic/borders' as borders;
|
||||
@use 'ui-design-system/src/styles/semantic/typography' as typography;
|
||||
|
||||
// FOCUS INDICATOR TOKENS
|
||||
$a11y-focus-color: colors.$semantic-color-focus !default;
|
||||
$a11y-focus-ring-color: colors.$semantic-color-focus-ring !default;
|
||||
$a11y-focus-ring-width: borders.$semantic-border-focus-width !default;
|
||||
$a11y-focus-shadow: shadows.$semantic-shadow-input-focus !default;
|
||||
$a11y-focus-transition-duration: motion.$semantic-motion-duration-fast !default;
|
||||
$a11y-focus-transition-timing: motion.$semantic-motion-easing-ease-out !default;
|
||||
|
||||
// SKIP LINKS TOKENS
|
||||
$a11y-skip-link-bg: colors.$semantic-color-surface-primary !default;
|
||||
$a11y-skip-link-color: colors.$semantic-color-text-primary !default;
|
||||
$a11y-skip-link-border: colors.$semantic-color-border-focus !default;
|
||||
$a11y-skip-link-padding-x: spacing.$semantic-spacing-interactive-button-padding-x !default;
|
||||
$a11y-skip-link-padding-y: spacing.$semantic-spacing-interactive-button-padding-y !default;
|
||||
$a11y-skip-link-border-radius: borders.$semantic-border-radius-md !default;
|
||||
$a11y-skip-link-shadow: shadows.$semantic-shadow-button-focus !default;
|
||||
$a11y-skip-link-z-index: 10000 !default;
|
||||
|
||||
// SCREEN READER ONLY TOKENS
|
||||
$a11y-sr-only-position: absolute !default;
|
||||
$a11y-sr-only-width: 1px !default;
|
||||
$a11y-sr-only-height: 1px !default;
|
||||
$a11y-sr-only-margin: -1px !default;
|
||||
$a11y-sr-only-padding: 0 !default;
|
||||
$a11y-sr-only-overflow: hidden !default;
|
||||
$a11y-sr-only-clip: rect(0, 0, 0, 0) !default;
|
||||
$a11y-sr-only-border: 0 !default;
|
||||
|
||||
// HIGH CONTRAST MODE TOKENS
|
||||
$a11y-high-contrast-border-color: ButtonText !default;
|
||||
$a11y-high-contrast-bg-color: ButtonFace !default;
|
||||
$a11y-high-contrast-text-color: ButtonText !default;
|
||||
$a11y-high-contrast-focus-color: Highlight !default;
|
||||
|
||||
// REDUCED MOTION TOKENS
|
||||
$a11y-reduced-motion-duration: motion.$semantic-motion-duration-instant !default;
|
||||
$a11y-reduced-motion-easing: motion.$semantic-motion-easing-linear !default;
|
||||
|
||||
// TOUCH TARGET TOKENS
|
||||
$a11y-min-touch-target: spacing.$semantic-spacing-interactive-touch-target !default;
|
||||
|
||||
// LIVE REGION TOKENS
|
||||
$a11y-live-region-position: absolute !default;
|
||||
$a11y-live-region-left: -10000px !default;
|
||||
$a11y-live-region-width: 1px !default;
|
||||
$a11y-live-region-height: 1px !default;
|
||||
$a11y-live-region-overflow: hidden !default;
|
||||
|
||||
// ERROR AND SUCCESS COLORS FOR HIGH CONTRAST
|
||||
$a11y-error-color: colors.$semantic-color-error !default;
|
||||
$a11y-success-color: colors.$semantic-color-success !default;
|
||||
$a11y-warning-color: colors.$semantic-color-warning !default;
|
||||
|
||||
// ANIMATION TOKENS FOR ACCESSIBILITY
|
||||
$a11y-animation-fade-duration: motion.$semantic-motion-duration-normal !default;
|
||||
$a11y-animation-slide-duration: motion.$semantic-motion-duration-normal !default;
|
||||
$a11y-animation-scale-duration: motion.$semantic-motion-duration-fast !default;
|
||||
|
||||
// FOCUS TRAP TOKENS
|
||||
$a11y-focus-trap-outline-color: colors.$semantic-color-focus !default;
|
||||
$a11y-focus-trap-outline-width: 2px !default;
|
||||
$a11y-focus-trap-outline-style: solid !default;
|
||||
$a11y-focus-trap-outline-offset: 2px !default;
|
||||
106
projects/ui-accessibility/src/lib/ui-accessibility.module.ts
Normal file
106
projects/ui-accessibility/src/lib/ui-accessibility.module.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NgModule, ModuleWithProviders, inject, APP_INITIALIZER } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
// Services
|
||||
import { LiveAnnouncerService } from './services/live-announcer';
|
||||
import { FocusMonitorService } from './services/focus-monitor';
|
||||
import { KeyboardManagerService } from './services/keyboard-manager';
|
||||
import { HighContrastService } from './services/high-contrast';
|
||||
import { A11yConfigService, A11yConfiguration, A11Y_CONFIG } from './services/a11y-config';
|
||||
|
||||
// Directives
|
||||
import { FocusTrapDirective } from './directives/focus-trap';
|
||||
import { ArrowNavigationDirective } from './directives/arrow-navigation';
|
||||
|
||||
// Components
|
||||
import { SkipLinksComponent } from './components/skip-links';
|
||||
import { ScreenReaderOnlyComponent } from './components/screen-reader-only';
|
||||
|
||||
const DIRECTIVES = [
|
||||
FocusTrapDirective,
|
||||
ArrowNavigationDirective
|
||||
];
|
||||
|
||||
const COMPONENTS = [
|
||||
SkipLinksComponent,
|
||||
ScreenReaderOnlyComponent
|
||||
];
|
||||
|
||||
/**
|
||||
* Initialize accessibility features based on configuration
|
||||
*/
|
||||
export function initializeA11y(
|
||||
config: A11yConfigService,
|
||||
highContrast: HighContrastService
|
||||
): () => void {
|
||||
return () => {
|
||||
// Apply body classes for accessibility preferences
|
||||
if (config.isEnabled('accessibility.addBodyClasses')) {
|
||||
highContrast.applyPreferencesToBody();
|
||||
}
|
||||
|
||||
// Inject accessibility styles
|
||||
if (config.isEnabled('accessibility.injectAccessibilityStyles')) {
|
||||
highContrast.injectReducedMotionStyles();
|
||||
}
|
||||
|
||||
// Log initialization
|
||||
config.log('UI Accessibility module initialized', config.getConfig());
|
||||
};
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
...DIRECTIVES,
|
||||
...COMPONENTS
|
||||
],
|
||||
exports: [
|
||||
...DIRECTIVES,
|
||||
...COMPONENTS
|
||||
]
|
||||
})
|
||||
export class UiAccessibilityModule {
|
||||
static forRoot(config?: Partial<A11yConfiguration>): ModuleWithProviders<UiAccessibilityModule> {
|
||||
return {
|
||||
ngModule: UiAccessibilityModule,
|
||||
providers: [
|
||||
// Core services
|
||||
LiveAnnouncerService,
|
||||
FocusMonitorService,
|
||||
KeyboardManagerService,
|
||||
HighContrastService,
|
||||
A11yConfigService,
|
||||
|
||||
// Configuration
|
||||
{
|
||||
provide: A11Y_CONFIG,
|
||||
useValue: config || {}
|
||||
},
|
||||
|
||||
// Initialize accessibility features
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: initializeA11y,
|
||||
deps: [A11yConfigService, HighContrastService],
|
||||
multi: true
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
static forChild(): ModuleWithProviders<UiAccessibilityModule> {
|
||||
return {
|
||||
ngModule: UiAccessibilityModule,
|
||||
providers: []
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Module loaded
|
||||
if (typeof console !== 'undefined') {
|
||||
console.log('🔍 UI Accessibility module loaded');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { UiAccessibilityService } from './ui-accessibility.service';
|
||||
|
||||
describe('UiAccessibilityService', () => {
|
||||
let service: UiAccessibilityService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(UiAccessibilityService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UiAccessibilityService {
|
||||
|
||||
constructor() { }
|
||||
}
|
||||
243
projects/ui-accessibility/src/lib/utilities/a11y-utilities.scss
Normal file
243
projects/ui-accessibility/src/lib/utilities/a11y-utilities.scss
Normal file
@@ -0,0 +1,243 @@
|
||||
// ==========================================================================
|
||||
// ACCESSIBILITY UTILITIES
|
||||
// ==========================================================================
|
||||
// Main accessibility utility file that imports all utilities
|
||||
// ==========================================================================
|
||||
|
||||
@use '../tokens/a11y-tokens' as tokens;
|
||||
|
||||
// Import all utility modules
|
||||
@forward 'focus-visible';
|
||||
@forward 'screen-reader';
|
||||
|
||||
// Skip links styles
|
||||
.skip-links {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: tokens.$a11y-skip-link-z-index;
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: tokens.$a11y-skip-link-bg;
|
||||
color: tokens.$a11y-skip-link-color;
|
||||
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
|
||||
text-decoration: none;
|
||||
border: 1px solid tokens.$a11y-skip-link-border;
|
||||
border-radius: tokens.$a11y-skip-link-border-radius;
|
||||
box-shadow: tokens.$a11y-skip-link-shadow;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-10px);
|
||||
transition: all tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
|
||||
|
||||
&:focus {
|
||||
top: 6px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
// Multiple skip links positioning
|
||||
&:nth-child(2):focus {
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
&:nth-child(3):focus {
|
||||
top: 94px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Touch target sizing
|
||||
.touch-target {
|
||||
min-height: tokens.$a11y-min-touch-target;
|
||||
min-width: tokens.$a11y-min-touch-target;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Error announcements
|
||||
.error-announcement {
|
||||
@extend .sr-only;
|
||||
|
||||
&.error-visible {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
color: tokens.$a11y-error-color;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading announcements
|
||||
.loading-announcement {
|
||||
@extend .sr-only;
|
||||
}
|
||||
|
||||
// Keyboard navigation indicators
|
||||
.keyboard-navigation {
|
||||
// Hide mouse focus indicators when using keyboard navigation
|
||||
&.using-mouse {
|
||||
* {
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced focus indicators when using keyboard
|
||||
&.using-keyboard {
|
||||
*:focus-visible {
|
||||
outline: 2px solid tokens.$a11y-focus-color;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// High contrast mode utilities
|
||||
@media (prefers-contrast: high) {
|
||||
.high-contrast-border {
|
||||
border: 1px solid tokens.$a11y-high-contrast-border-color !important;
|
||||
}
|
||||
|
||||
.high-contrast-bg {
|
||||
background: tokens.$a11y-high-contrast-bg-color !important;
|
||||
color: tokens.$a11y-high-contrast-text-color !important;
|
||||
}
|
||||
|
||||
// Remove shadows and transparency in high contrast
|
||||
* {
|
||||
text-shadow: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// Ensure sufficient contrast for interactive elements
|
||||
button,
|
||||
[role="button"],
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid tokens.$a11y-high-contrast-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion utilities
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.respect-reduced-motion {
|
||||
* {
|
||||
animation-duration: tokens.$a11y-reduced-motion-duration !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: tokens.$a11y-reduced-motion-duration !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ARIA live region styling
|
||||
.aria-live-region {
|
||||
@extend .live-region;
|
||||
|
||||
&.aria-live-assertive {
|
||||
speak: literal;
|
||||
}
|
||||
|
||||
&.aria-live-polite {
|
||||
speak: spell-out;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus management utilities
|
||||
.focus-within-highlight {
|
||||
&:focus-within {
|
||||
outline: 1px solid tokens.$a11y-focus-color;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Accessible hide/show animations
|
||||
@keyframes fadeInA11y {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutA11y {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-a11y {
|
||||
animation: fadeInA11y tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing;
|
||||
}
|
||||
|
||||
.fade-out-a11y {
|
||||
animation: fadeOutA11y tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing;
|
||||
}
|
||||
|
||||
// Respect reduced motion for animations
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in-a11y,
|
||||
.fade-out-a11y {
|
||||
animation-duration: tokens.$a11y-reduced-motion-duration;
|
||||
animation-timing-function: tokens.$a11y-reduced-motion-easing;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow navigation visual indicators (dev mode)
|
||||
.arrow-navigation-active {
|
||||
&.arrow-navigation-debug {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '← ↑ ↓ → Arrow Navigation';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: 0;
|
||||
font-size: 12px;
|
||||
color: tokens.$a11y-focus-color;
|
||||
background: tokens.$a11y-skip-link-bg;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight navigable items
|
||||
[tabindex="0"] {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
border: 1px dashed tokens.$a11y-focus-color;
|
||||
pointer-events: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
145
projects/ui-accessibility/src/lib/utilities/focus-visible.scss
Normal file
145
projects/ui-accessibility/src/lib/utilities/focus-visible.scss
Normal file
@@ -0,0 +1,145 @@
|
||||
// ==========================================================================
|
||||
// FOCUS VISIBLE UTILITIES
|
||||
// ==========================================================================
|
||||
// Focus management styles using semantic tokens
|
||||
// ==========================================================================
|
||||
|
||||
@use '../tokens/a11y-tokens' as tokens;
|
||||
|
||||
// Base focus visible styles
|
||||
.focus-visible,
|
||||
:focus-visible {
|
||||
outline: tokens.$a11y-focus-trap-outline-width tokens.$a11y-focus-trap-outline-style tokens.$a11y-focus-outline-color;
|
||||
outline-offset: tokens.$a11y-focus-trap-outline-offset;
|
||||
transition: outline-color tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
|
||||
}
|
||||
|
||||
// Remove default focus outline for mouse users
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// Focus ring for interactive elements
|
||||
.focus-ring {
|
||||
position: relative;
|
||||
|
||||
&:focus-visible::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
border: tokens.$a11y-focus-ring-width solid tokens.$a11y-focus-color;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
transition: opacity tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:not(:focus-visible)::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus styles for buttons
|
||||
.btn-focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible {
|
||||
outline: tokens.$a11y-focus-trap-outline-width solid tokens.$a11y-focus-color;
|
||||
outline-offset: 2px;
|
||||
box-shadow: tokens.$a11y-focus-shadow;
|
||||
}
|
||||
|
||||
// Focus styles for form inputs
|
||||
.input-focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
border-color: tokens.$a11y-focus-color;
|
||||
box-shadow: tokens.$a11y-focus-shadow;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Focus styles for links
|
||||
.link-focus-visible,
|
||||
a:focus-visible {
|
||||
outline: tokens.$a11y-focus-trap-outline-width solid tokens.$a11y-focus-color;
|
||||
outline-offset: 2px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// High contrast mode support
|
||||
@media (prefers-contrast: high) {
|
||||
.focus-visible,
|
||||
:focus-visible {
|
||||
outline-color: tokens.$a11y-high-contrast-focus-color;
|
||||
outline-width: 3px;
|
||||
}
|
||||
|
||||
.focus-ring:focus-visible::after {
|
||||
border-color: tokens.$a11y-high-contrast-focus-color;
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion support
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.focus-visible,
|
||||
:focus-visible,
|
||||
.focus-ring::after {
|
||||
transition-duration: tokens.$a11y-reduced-motion-duration;
|
||||
transition-timing-function: tokens.$a11y-reduced-motion-easing;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus trap container styles
|
||||
.focus-trap-container {
|
||||
position: relative;
|
||||
|
||||
&.focus-trap-active {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
// Visual indicator for focus trap (dev mode)
|
||||
&.focus-trap-debug {
|
||||
&::before {
|
||||
content: 'Focus Trap Active';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 0;
|
||||
font-size: 12px;
|
||||
color: tokens.$a11y-focus-color;
|
||||
background: tokens.$a11y-skip-link-bg;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip to content on focus trap
|
||||
.focus-trap-skip {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: tokens.$a11y-skip-link-bg;
|
||||
color: tokens.$a11y-skip-link-color;
|
||||
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
|
||||
text-decoration: none;
|
||||
border: 1px solid tokens.$a11y-skip-link-border;
|
||||
border-radius: tokens.$a11y-skip-link-border-radius;
|
||||
z-index: tokens.$a11y-skip-link-z-index;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-10px);
|
||||
transition: all tokens.$a11y-focus-transition-duration tokens.$a11y-focus-transition-timing;
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
box-shadow: tokens.$a11y-skip-link-shadow;
|
||||
}
|
||||
}
|
||||
167
projects/ui-accessibility/src/lib/utilities/screen-reader.scss
Normal file
167
projects/ui-accessibility/src/lib/utilities/screen-reader.scss
Normal file
@@ -0,0 +1,167 @@
|
||||
// ==========================================================================
|
||||
// SCREEN READER UTILITIES
|
||||
// ==========================================================================
|
||||
// Screen reader specific styles using semantic tokens
|
||||
// ==========================================================================
|
||||
|
||||
@use '../tokens/a11y-tokens' as tokens;
|
||||
|
||||
// Screen reader only - visually hidden but accessible
|
||||
.sr-only {
|
||||
position: tokens.$a11y-sr-only-position;
|
||||
width: tokens.$a11y-sr-only-width;
|
||||
height: tokens.$a11y-sr-only-height;
|
||||
padding: tokens.$a11y-sr-only-padding;
|
||||
margin: tokens.$a11y-sr-only-margin;
|
||||
overflow: tokens.$a11y-sr-only-overflow;
|
||||
clip: tokens.$a11y-sr-only-clip;
|
||||
white-space: nowrap;
|
||||
border: tokens.$a11y-sr-only-border;
|
||||
}
|
||||
|
||||
// Screen reader only that becomes visible on focus
|
||||
.sr-only-focusable {
|
||||
@extend .sr-only;
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
background: tokens.$a11y-skip-link-bg;
|
||||
color: tokens.$a11y-skip-link-color;
|
||||
border: 1px solid tokens.$a11y-skip-link-border;
|
||||
border-radius: tokens.$a11y-skip-link-border-radius;
|
||||
box-shadow: tokens.$a11y-skip-link-shadow;
|
||||
z-index: tokens.$a11y-skip-link-z-index;
|
||||
}
|
||||
}
|
||||
|
||||
// Live region for screen reader announcements
|
||||
.live-region {
|
||||
position: tokens.$a11y-live-region-position;
|
||||
left: tokens.$a11y-live-region-left;
|
||||
width: tokens.$a11y-live-region-width;
|
||||
height: tokens.$a11y-live-region-height;
|
||||
overflow: tokens.$a11y-live-region-overflow;
|
||||
}
|
||||
|
||||
// Status message styling
|
||||
.status-message {
|
||||
@extend .sr-only;
|
||||
|
||||
&.status-visible {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
padding: tokens.$a11y-skip-link-padding-y tokens.$a11y-skip-link-padding-x;
|
||||
margin: 4px 0;
|
||||
background: tokens.$a11y-skip-link-bg;
|
||||
border-left: 4px solid tokens.$a11y-focus-color;
|
||||
border-radius: 0 tokens.$a11y-skip-link-border-radius tokens.$a11y-skip-link-border-radius 0;
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
border-left-color: tokens.$a11y-error-color;
|
||||
}
|
||||
|
||||
&.status-success {
|
||||
border-left-color: tokens.$a11y-success-color;
|
||||
}
|
||||
|
||||
&.status-warning {
|
||||
border-left-color: tokens.$a11y-warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Screen reader instructions
|
||||
.sr-instructions {
|
||||
@extend .sr-only;
|
||||
|
||||
// Common instruction patterns
|
||||
&.sr-required::after {
|
||||
content: ' (required)';
|
||||
}
|
||||
|
||||
&.sr-optional::after {
|
||||
content: ' (optional)';
|
||||
}
|
||||
|
||||
&.sr-expanded::after {
|
||||
content: ' (expanded)';
|
||||
}
|
||||
|
||||
&.sr-collapsed::after {
|
||||
content: ' (collapsed)';
|
||||
}
|
||||
|
||||
&.sr-selected::after {
|
||||
content: ' (selected)';
|
||||
}
|
||||
|
||||
&.sr-current::after {
|
||||
content: ' (current)';
|
||||
}
|
||||
}
|
||||
|
||||
// Accessible hide/show with transitions
|
||||
.a11y-hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.95);
|
||||
transition: opacity tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing,
|
||||
transform tokens.$a11y-animation-scale-duration tokens.$a11y-focus-transition-timing;
|
||||
|
||||
// Still accessible to screen readers when hidden
|
||||
&:not(.a11y-hide-completely) {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
// Completely hidden including from screen readers
|
||||
&.a11y-hide-completely {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.a11y-show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
transition: opacity tokens.$a11y-animation-fade-duration tokens.$a11y-focus-transition-timing,
|
||||
transform tokens.$a11y-animation-scale-duration tokens.$a11y-focus-transition-timing;
|
||||
}
|
||||
|
||||
// High contrast mode adjustments
|
||||
@media (prefers-contrast: high) {
|
||||
.sr-only-focusable:focus,
|
||||
.sr-only-focusable:active {
|
||||
background: tokens.$a11y-high-contrast-bg-color;
|
||||
color: tokens.$a11y-high-contrast-text-color;
|
||||
border-color: tokens.$a11y-high-contrast-border-color;
|
||||
}
|
||||
|
||||
.status-message.status-visible {
|
||||
background: tokens.$a11y-high-contrast-bg-color;
|
||||
color: tokens.$a11y-high-contrast-text-color;
|
||||
border-color: tokens.$a11y-high-contrast-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion support
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.a11y-hide,
|
||||
.a11y-show,
|
||||
.sr-only-focusable {
|
||||
transition-duration: tokens.$a11y-reduced-motion-duration;
|
||||
transition-timing-function: tokens.$a11y-reduced-motion-easing;
|
||||
}
|
||||
}
|
||||
21
projects/ui-accessibility/src/public-api.ts
Normal file
21
projects/ui-accessibility/src/public-api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Public API Surface of ui-accessibility
|
||||
*/
|
||||
|
||||
// Main module
|
||||
export * from './lib/ui-accessibility.module';
|
||||
|
||||
// Services
|
||||
export * from './lib/services/live-announcer';
|
||||
export * from './lib/services/focus-monitor';
|
||||
export * from './lib/services/keyboard-manager';
|
||||
export * from './lib/services/high-contrast';
|
||||
export * from './lib/services/a11y-config';
|
||||
|
||||
// Directives
|
||||
export * from './lib/directives/focus-trap';
|
||||
export * from './lib/directives/arrow-navigation';
|
||||
|
||||
// Components
|
||||
export * from './lib/components/skip-links';
|
||||
export * from './lib/components/screen-reader-only';
|
||||
15
projects/ui-accessibility/tsconfig.lib.json
Normal file
15
projects/ui-accessibility/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
11
projects/ui-accessibility/tsconfig.lib.prod.json
Normal file
11
projects/ui-accessibility/tsconfig.lib.prod.json
Normal file
@@ -0,0 +1,11 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
15
projects/ui-accessibility/tsconfig.spec.json
Normal file
15
projects/ui-accessibility/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
164
projects/ui-animations/README.md
Normal file
164
projects/ui-animations/README.md
Normal 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.
|
||||
7
projects/ui-animations/ng-package.json
Normal file
7
projects/ui-animations/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/ui-animations",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
14
projects/ui-animations/package.json
Normal file
14
projects/ui-animations/package.json
Normal 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
|
||||
}
|
||||
66
projects/ui-animations/src/lib/animate.directive.ts
Normal file
66
projects/ui-animations/src/lib/animate.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
projects/ui-animations/src/lib/ui-animations.service.spec.ts
Normal file
16
projects/ui-animations/src/lib/ui-animations.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
46
projects/ui-animations/src/lib/ui-animations.service.ts
Normal file
46
projects/ui-animations/src/lib/ui-animations.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
projects/ui-animations/src/public-api.ts
Normal file
6
projects/ui-animations/src/public-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* Public API Surface of ui-animations
|
||||
*/
|
||||
|
||||
export * from './lib/ui-animations.service';
|
||||
export * from './lib/animate.directive';
|
||||
142
projects/ui-animations/src/styles/animations/_emphasis.scss
Normal file
142
projects/ui-animations/src/styles/animations/_emphasis.scss
Normal 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;
|
||||
}
|
||||
151
projects/ui-animations/src/styles/animations/_entrances.scss
Normal file
151
projects/ui-animations/src/styles/animations/_entrances.scss
Normal 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;
|
||||
}
|
||||
151
projects/ui-animations/src/styles/animations/_exits.scss
Normal file
151
projects/ui-animations/src/styles/animations/_exits.scss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Base Transitions
|
||||
// Smooth transitions for common properties
|
||||
|
||||
// Transition utilities
|
||||
.transition-all {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.transition-opacity {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.transition-transform {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.transition-colors {
|
||||
transition: color, background-color, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
// Transition durations
|
||||
.transition-fast {
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
.transition-normal {
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.transition-slow {
|
||||
transition-duration: 0.6s;
|
||||
}
|
||||
|
||||
// Transition timing functions
|
||||
.transition-linear {
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
|
||||
.transition-ease-in {
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
.transition-ease-out {
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.transition-ease-in-out {
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.transition-bounce {
|
||||
transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
21
projects/ui-animations/src/styles/index.scss
Normal file
21
projects/ui-animations/src/styles/index.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
// UI Animations - Main Entry Point
|
||||
// Import all animation styles
|
||||
|
||||
// Animations
|
||||
@forward 'animations/transitions';
|
||||
@forward 'animations/entrances';
|
||||
@forward 'animations/exits';
|
||||
@forward 'animations/emphasis';
|
||||
|
||||
// Utilities
|
||||
@forward 'utilities/animation-utilities';
|
||||
|
||||
// Mixins (use @use to access mixins in your components)
|
||||
@forward 'mixins/animation-mixins';
|
||||
|
||||
// Export all animations and utilities
|
||||
@use 'animations/transitions';
|
||||
@use 'animations/entrances';
|
||||
@use 'animations/exits';
|
||||
@use 'animations/emphasis';
|
||||
@use 'utilities/animation-utilities';
|
||||
130
projects/ui-animations/src/styles/mixins/_animation-mixins.scss
Normal file
130
projects/ui-animations/src/styles/mixins/_animation-mixins.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
// Animation Mixins
|
||||
// Reusable SCSS mixins for creating custom animations
|
||||
|
||||
|
||||
// Generic animation mixin
|
||||
@mixin animate(
|
||||
$name,
|
||||
$duration: 0.3s,
|
||||
$timing-function: ease-out,
|
||||
$delay: motion.$semantic-motion-transition-delay-0,
|
||||
$iteration-count: 1,
|
||||
$direction: normal,
|
||||
$fill-mode: both
|
||||
) {
|
||||
animation-name: $name;
|
||||
animation-duration: $duration;
|
||||
animation-timing-function: $timing-function;
|
||||
animation-delay: $delay;
|
||||
animation-iteration-count: $iteration-count;
|
||||
animation-direction: $direction;
|
||||
animation-fill-mode: $fill-mode;
|
||||
}
|
||||
|
||||
// Transition mixin with design system tokens
|
||||
@mixin transition(
|
||||
$property: motion.$semantic-motion-transition-property-all,
|
||||
$duration: 0.3s,
|
||||
$timing-function: ease,
|
||||
$delay: motion.$semantic-motion-transition-delay-0
|
||||
) {
|
||||
transition-property: $property;
|
||||
transition-duration: $duration;
|
||||
transition-timing-function: $timing-function;
|
||||
transition-delay: $delay;
|
||||
}
|
||||
|
||||
// Fade animation mixin
|
||||
@mixin fade-animation($direction: 'in', $distance: 24px) {
|
||||
@if $direction == 'in' {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY($distance);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
} @else {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-#{$distance});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slide animation mixin
|
||||
@mixin slide-animation($direction: 'up', $distance: 100%) {
|
||||
@if $direction == 'up' {
|
||||
from { transform: translateY($distance); }
|
||||
to { transform: translateY(0); }
|
||||
} @else if $direction == 'down' {
|
||||
from { transform: translateY(-$distance); }
|
||||
to { transform: translateY(0); }
|
||||
} @else if $direction == 'left' {
|
||||
from { transform: translateX($distance); }
|
||||
to { transform: translateX(0); }
|
||||
} @else if $direction == 'right' {
|
||||
from { transform: translateX(-$distance); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
}
|
||||
|
||||
// Scale animation mixin
|
||||
@mixin scale-animation($from: 0, $to: 1) {
|
||||
from {
|
||||
transform: scale($from);
|
||||
}
|
||||
to {
|
||||
transform: scale($to);
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate animation mixin
|
||||
@mixin rotate-animation($from: 0deg, $to: 360deg) {
|
||||
from {
|
||||
transform: rotate($from);
|
||||
}
|
||||
to {
|
||||
transform: rotate($to);
|
||||
}
|
||||
}
|
||||
|
||||
// Hover animation mixin
|
||||
@mixin hover-animation($property: transform, $value: scale(1.05), $duration: 0.15s) {
|
||||
transition: $property $duration ease;
|
||||
|
||||
&:hover {
|
||||
#{$property}: $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading animation mixin
|
||||
@mixin loading-animation($size: 40px, $color: currentColor) {
|
||||
width: $size;
|
||||
height: $size;
|
||||
border: 3px solid rgba($color, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: $color;
|
||||
animation: spin 0.6sest ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Keyframes for common animations
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Staggered animation mixin for lists
|
||||
@mixin staggered-animation($base-delay: motion.$semantic-motion-transition-delay-100, $animation: fadeInUp 0.3s ease-out) {
|
||||
@for $i from 1 through 10 {
|
||||
&:nth-child(#{$i}) {
|
||||
animation: $animation;
|
||||
animation-delay: #{$base-delay * $i};
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Animation Utilities
|
||||
// Helper classes for animation control
|
||||
|
||||
|
||||
// Animation delays
|
||||
.animation-delay-100 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.animation-delay-300 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.animation-delay-500 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.animation-delay-1000 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
// Animation durations
|
||||
.animation-duration-fast {
|
||||
animation-duration: 0.15s;
|
||||
}
|
||||
|
||||
.animation-duration-normal {
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
|
||||
.animation-duration-slow {
|
||||
animation-duration: 0.6s;
|
||||
}
|
||||
|
||||
.animation-duration-slower {
|
||||
animation-duration: 1s;
|
||||
}
|
||||
|
||||
// Animation fill modes
|
||||
.animation-fill-none {
|
||||
animation-fill-mode: none;
|
||||
}
|
||||
|
||||
.animation-fill-forwards {
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.animation-fill-backwards {
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
.animation-fill-both {
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
// Animation iteration counts
|
||||
.animation-infinite {
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.animation-once {
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
.animation-twice {
|
||||
animation-iteration-count: 2;
|
||||
}
|
||||
|
||||
// Animation play states
|
||||
.animation-paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.animation-running {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
// Animation directions
|
||||
.animation-normal {
|
||||
animation-direction: normal;
|
||||
}
|
||||
|
||||
.animation-reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.animation-alternate {
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
.animation-alternate-reverse {
|
||||
animation-direction: alternate-reverse;
|
||||
}
|
||||
|
||||
// Animation timing functions
|
||||
.animation-linear {
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
.animation-ease-in {
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
|
||||
.animation-ease-out {
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.animation-ease-in-out {
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.animation-bounce-timing {
|
||||
animation-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user