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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
1
src/lib/components/data-display/image-container/index.ts
Normal file
1
src/lib/components/data-display/image-container/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './image-container.component';
|
||||
Reference in New Issue
Block a user