Initial commit - Angular library: ui-essentials

🎯 Implementation Complete!

This library has been extracted from the monorepo and is ready for Git submodule distribution.

Features:
- Standardized SCSS imports (no relative paths)
- Optimized public-api.ts exports
- Independent Angular library structure
- Ready for consumer integration as submodule

🚀 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jules
2025-09-11 21:12:46 +10:00
commit 0a0cade343
302 changed files with 54198 additions and 0 deletions

View File

@@ -0,0 +1,314 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
.ui-image-container {
// Core Structure
position: relative;
display: inline-block;
overflow: hidden;
// Layout & Aspect Ratio
aspect-ratio: var(--aspect-ratio, 1/1);
// Visual Design
background: $semantic-color-surface-secondary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-md;
// Transitions
transition: all $semantic-duration-fast $semantic-easing-standard;
// Size Variants
&--size-sm {
min-width: $semantic-sizing-card-width-sm * 0.5; // 8rem
max-width: $semantic-sizing-card-width-sm; // 16rem
min-height: $semantic-sizing-card-height-sm * 0.5; // 4rem
}
&--size-md {
min-width: $semantic-sizing-card-width-md * 0.5; // 10rem
max-width: $semantic-sizing-card-width-md; // 20rem
min-height: $semantic-sizing-card-height-md * 0.5; // 6rem
}
&--size-lg {
min-width: $semantic-sizing-card-width-lg * 0.5; // 12rem
max-width: $semantic-sizing-card-width-lg; // 24rem
min-height: $semantic-sizing-card-height-lg * 0.5; // 8rem
}
&--size-xl {
min-width: $semantic-sizing-card-width-lg * 0.75; // 18rem
max-width: $semantic-sizing-content-narrow; // 42rem
min-height: $semantic-sizing-card-height-lg * 0.75; // 12rem
}
// Aspect Ratio Variants
&--aspect-1-1 { aspect-ratio: 1/1; }
&--aspect-4-3 { aspect-ratio: 4/3; }
&--aspect-16-9 { aspect-ratio: 16/9; }
&--aspect-3-2 { aspect-ratio: 3/2; }
&--aspect-2-1 { aspect-ratio: 2/1; }
&--aspect-3-4 { aspect-ratio: 3/4; }
&--aspect-9-16 { aspect-ratio: 9/16; }
// Shape Variants
&--shape-square {
border-radius: $semantic-border-radius-sm;
}
&--shape-rounded {
border-radius: $semantic-border-radius-md;
}
&--shape-circle {
border-radius: $semantic-border-radius-full;
aspect-ratio: 1/1;
}
// State Variants
&--loading {
cursor: wait;
}
&--error {
border-color: $semantic-color-border-error;
border-style: dashed;
}
&--lazy {
background: $semantic-color-surface-secondary;
&:not(.ui-image-container--loaded) {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
// Interactive States
&:not(.ui-image-container--error) {
&:hover {
box-shadow: $semantic-shadow-elevation-2;
transform: translateY(-1px);
border-color: $semantic-color-border-primary;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus-ring;
outline-offset: 2px;
}
&:active {
transform: translateY(0);
box-shadow: $semantic-shadow-elevation-1;
}
}
// Image Element
&__image {
width: 100%;
height: 100%;
display: block;
// Object Fit Variants
&--fit-contain { object-fit: contain; }
&--fit-cover { object-fit: cover; }
&--fit-fill { object-fit: fill; }
&--fit-scale-down { object-fit: scale-down; }
&--fit-none { object-fit: none; }
// Smooth appearance transition
opacity: 0;
transition: opacity $semantic-duration-medium $semantic-easing-standard;
.ui-image-container--loaded & {
opacity: 1;
}
}
// Loading State
&__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: $semantic-color-surface-secondary;
z-index: 2;
}
&__spinner {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
border: 2px solid $semantic-color-border-primary;
border-top: 2px solid $semantic-color-interactive-primary;
border-radius: $semantic-border-radius-full;
animation: spin 1s linear infinite;
}
// Error State
&__error {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $semantic-spacing-component-xs;
background: $semantic-color-surface-secondary;
color: $semantic-color-danger;
z-index: 2;
padding: $semantic-spacing-component-sm;
text-align: center;
}
&__error-icon {
color: $semantic-color-text-secondary;
svg {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
}
}
&__error-text {
font-size: $semantic-typography-font-size-xs;
color: $semantic-color-text-secondary;
font-weight: $semantic-typography-font-weight-medium;
}
// Overlay
&__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity $semantic-duration-fast $semantic-easing-standard;
z-index: 3;
.ui-image-container:hover & {
opacity: 1;
}
}
// Caption
&__caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: $semantic-spacing-component-md $semantic-spacing-component-sm $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-tight;
z-index: 4;
}
// Dark Mode Support
:host-context(.dark-theme) & {
background: $semantic-color-surface-primary;
border-color: $semantic-color-border-subtle;
&__loading {
background: $semantic-color-surface-primary;
}
&__spinner {
border-color: $semantic-color-border-subtle;
border-top-color: $semantic-color-interactive-primary;
}
}
// Responsive Design
@media (max-width: $semantic-sizing-breakpoint-tablet - 1) {
&--size-xl {
max-width: $semantic-sizing-card-width-lg;
}
&--size-lg {
max-width: $semantic-sizing-card-width-md;
}
}
@media (max-width: $semantic-sizing-breakpoint-mobile - 1) {
// Mobile adjustments
&__caption {
font-size: $semantic-typography-font-size-xs;
padding: $semantic-spacing-component-xs;
}
&__error-text {
font-size: $semantic-typography-font-size-xs;
}
}
// Accessibility - Reduced motion
@media (prefers-reduced-motion: reduce) {
transition: none;
&__image {
transition: none;
}
&__overlay {
transition: none;
}
&--lazy:not(.ui-image-container--loaded) {
animation: none;
}
&__spinner {
animation: none;
}
}
}
// Animation Keyframes
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
// Utility classes for content projection
.ui-image-container {
[slot='error'] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $semantic-spacing-component-xs;
padding: $semantic-spacing-component-sm;
text-align: center;
height: 100%;
color: $semantic-color-danger;
}
[slot='overlay'] {
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: $semantic-typography-font-weight-medium;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
[slot='caption'] {
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-tight;
}
}

View File

@@ -0,0 +1,217 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ImageContainerSize = 'sm' | 'md' | 'lg' | 'xl';
export type ImageContainerAspectRatio = '1/1' | '4/3' | '16/9' | '3/2' | '2/1' | '3/4' | '9/16';
export type ImageContainerObjectFit = 'contain' | 'cover' | 'fill' | 'scale-down' | 'none';
export type ImageContainerShape = 'square' | 'rounded' | 'circle';
@Component({
selector: 'ui-image-container',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-image-container"
[class]="getContainerClasses()"
[class.ui-image-container--loading]="loading"
[class.ui-image-container--error]="hasError"
[class.ui-image-container--lazy]="lazy"
[class.ui-image-container--loaded]="isLoaded"
[style.--aspect-ratio]="aspectRatio"
[attr.aria-label]="ariaLabel"
(click)="handleClick($event)">
@if (loading && !isLoaded) {
<div class="ui-image-container__loading" aria-hidden="true">
<div class="ui-image-container__spinner"></div>
</div>
}
@if (hasError) {
<div class="ui-image-container__error" role="img" [attr.aria-label]="errorAlt || 'Image failed to load'">
<ng-content select="[slot='error']">
<div class="ui-image-container__error-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
</div>
<span class="ui-image-container__error-text">Failed to load image</span>
</ng-content>
</div>
} @else {
<img
#imageElement
class="ui-image-container__image"
[class]="getImageClasses()"
[src]="currentSrc"
[alt]="alt || ''"
[loading]="lazy ? 'lazy' : 'eager'"
[style.object-fit]="objectFit"
(load)="handleLoad($event)"
(error)="handleError($event)"
(loadstart)="handleLoadStart()"
[attr.crossorigin]="crossorigin"
[attr.referrerpolicy]="referrerPolicy"
[attr.sizes]="sizes"
[attr.srcset]="srcset">
}
@if (overlay) {
<div class="ui-image-container__overlay">
<ng-content select="[slot='overlay']"></ng-content>
</div>
}
@if (caption) {
<div class="ui-image-container__caption">
<ng-content select="[slot='caption']">
<span>{{ caption }}</span>
</ng-content>
</div>
}
</div>
`,
styleUrl: './image-container.component.scss'
})
export class ImageContainerComponent implements OnInit, OnDestroy {
@Input() src!: string;
@Input() alt = '';
@Input() size: ImageContainerSize = 'md';
@Input() aspectRatio: ImageContainerAspectRatio = '1/1';
@Input() objectFit: ImageContainerObjectFit = 'cover';
@Input() shape: ImageContainerShape = 'rounded';
@Input() lazy = true;
@Input() loading = false;
@Input() placeholder?: string;
@Input() caption?: string;
@Input() overlay = false;
@Input() ariaLabel?: string;
@Input() errorAlt?: string;
// Advanced image attributes
@Input() srcset?: string;
@Input() sizes?: string;
@Input() crossorigin?: 'anonymous' | 'use-credentials';
@Input() referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
@Output() imageLoaded = new EventEmitter<Event>();
@Output() imageError = new EventEmitter<Event>();
@Output() imageClick = new EventEmitter<MouseEvent>();
@Output() loadingChange = new EventEmitter<boolean>();
private elementRef = inject(ElementRef);
private intersectionObserver?: IntersectionObserver;
hasError = false;
isLoaded = false;
currentSrc = '';
ngOnInit(): void {
this.initializeImage();
if (this.lazy && this.supportsIntersectionObserver()) {
this.setupLazyLoading();
} else {
this.loadImage();
}
}
ngOnDestroy(): void {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
}
private initializeImage(): void {
this.currentSrc = this.placeholder || this.src;
this.hasError = false;
this.isLoaded = false;
}
private setupLazyLoading(): void {
if (!this.supportsIntersectionObserver()) {
this.loadImage();
return;
}
this.intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
this.intersectionObserver?.unobserve(entry.target);
}
});
},
{
rootMargin: '50px 0px',
threshold: 0.01
}
);
this.intersectionObserver.observe(this.elementRef.nativeElement);
}
private loadImage(): void {
if (this.src && !this.isLoaded && !this.hasError) {
this.currentSrc = this.src;
this.loading = true;
this.loadingChange.emit(true);
}
}
private supportsIntersectionObserver(): boolean {
return 'IntersectionObserver' in window;
}
handleLoadStart(): void {
this.loading = true;
this.loadingChange.emit(true);
}
handleLoad(event: Event): void {
this.loading = false;
this.isLoaded = true;
this.hasError = false;
this.loadingChange.emit(false);
this.imageLoaded.emit(event);
}
handleError(event: Event): void {
this.loading = false;
this.hasError = true;
this.isLoaded = false;
this.loadingChange.emit(false);
this.imageError.emit(event);
}
handleClick(event: MouseEvent): void {
if (!this.hasError) {
this.imageClick.emit(event);
}
}
// Method to retry loading failed images
retryLoad(): void {
this.hasError = false;
this.isLoaded = false;
this.loadImage();
}
getContainerClasses(): string {
const aspectClass = `ui-image-container--aspect-${this.aspectRatio.replace('/', '-')}`;
const sizeClass = `ui-image-container--size-${this.size}`;
const shapeClass = `ui-image-container--shape-${this.shape}`;
return `ui-image-container ${sizeClass} ${aspectClass} ${shapeClass}`;
}
getImageClasses(): string {
const fitClass = `ui-image-container__image--fit-${this.objectFit}`;
return `ui-image-container__image ${fitClass}`;
}
}

View File

@@ -0,0 +1 @@
export * from './image-container.component';