Merged PR 1987: Carousel Lobrary

Related work items: #5408
This commit is contained in:
Lorenz Hilpert
2025-10-28 10:34:57 +00:00
committed by Nino Righi
parent bfd151dd84
commit c769af7021
20 changed files with 39726 additions and 38485 deletions

View File

@@ -10,6 +10,7 @@
@layer components {
@import "../../../libs/ui/buttons/src/buttons.scss";
@import "../../../libs/ui/bullet-list/src/bullet-list.scss";
@import "../../../libs/ui/carousel/src/lib/_carousel.scss";
@import "../../../libs/ui/datepicker/src/datepicker.scss";
@import "../../../libs/ui/dialog/src/dialog.scss";
@import "../../../libs/ui/input-controls/src/input-controls.scss";

View File

@@ -0,0 +1,141 @@
import { type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { CarouselComponent } from '@isa/ui/carousel';
import { QuoteCardComponent } from './quote-card.component';
// Collection of developer/inspirational quotes
const quotes = [
{ id: 1, text: 'Code is like humor. When you have to explain it, it\'s bad.', author: 'Cory House' },
{ id: 2, text: 'First, solve the problem. Then, write the code.', author: 'John Johnson' },
{ id: 3, text: 'Simplicity is the soul of efficiency.', author: 'Austin Freeman' },
{ id: 4, text: 'Make it work, make it right, make it fast.', author: 'Kent Beck' },
{ id: 5, text: 'Clean code always looks like it was written by someone who cares.', author: 'Robert C. Martin' },
{ id: 6, text: 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', author: 'Martin Fowler' },
{ id: 7, text: 'Experience is the name everyone gives to their mistakes.', author: 'Oscar Wilde' },
{ id: 8, text: 'In order to be irreplaceable, one must always be different.', author: 'Coco Chanel' },
{ id: 9, text: 'The best way to predict the future is to invent it.', author: 'Alan Kay' },
{ id: 10, text: 'Programs must be written for people to read, and only incidentally for machines to execute.', author: 'Harold Abelson' },
{ id: 11, text: 'Testing leads to failure, and failure leads to understanding.', author: 'Burt Rutan' },
{ id: 12, text: 'It\'s not a bug it\'s an undocumented feature.', author: 'Anonymous' },
{ id: 13, text: 'Software is a great combination between artistry and engineering.', author: 'Bill Gates' },
{ id: 14, text: 'Talk is cheap. Show me the code.', author: 'Linus Torvalds' },
{ id: 15, text: 'Sometimes it pays to stay in bed on Monday, rather than spending the rest of the week debugging Monday\'s code.', author: 'Dan Salomon' },
];
// Helper function to generate a specified number of quotes
function generateQuotes(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
result.push(quotes[i % quotes.length]);
}
return result;
}
interface CarouselStoryProps {
gap: string;
arrowAutoHide: boolean;
itemCount: number;
}
const meta: Meta<CarouselStoryProps> = {
component: CarouselComponent,
title: 'ui/carousel/Carousel',
decorators: [
moduleMetadata({
imports: [QuoteCardComponent],
}),
],
argTypes: {
gap: {
control: 'text',
description: 'CSS gap value for spacing between carousel items',
},
arrowAutoHide: {
control: 'boolean',
description: 'Whether to auto-hide arrows until carousel is hovered or focused',
},
itemCount: {
control: { type: 'number', min: 3, max: 20 },
description: 'Number of quote cards to render',
},
},
args: {
gap: '1rem',
arrowAutoHide: true,
itemCount: 6,
},
render: (args) => ({
props: {
...args,
quotes: generateQuotes(args.itemCount),
},
template: `
<div style="padding: 2.5rem; background: #f5f5f5;">
<ui-carousel
[gap]="gap"
[arrowAutoHide]="arrowAutoHide"
>
@for (quote of quotes; track quote.id) {
<quote-card [quote]="quote.text" [author]="quote.author"></quote-card>
}
</ui-carousel>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<CarouselStoryProps>;
/**
* Default carousel with 6 quote cards.
* Demonstrates basic horizontal scrolling with auto-hide arrows.
*/
export const Default: Story = {
args: {
itemCount: 6,
},
};
/**
* Carousel with many items (15 cards).
* Shows behavior with extensive scrolling and tests navigation performance.
*/
export const ManyItems: Story = {
args: {
itemCount: 15,
},
};
/**
* Carousel with few items (3 cards).
* Demonstrates behavior when content might not overflow.
* Arrows should disable if not scrollable.
*/
export const FewItems: Story = {
args: {
itemCount: 3,
},
};
/**
* Carousel with persistent arrows (no auto-hide).
* Arrows are always visible when content is scrollable.
*/
export const AlwaysShowArrows: Story = {
args: {
itemCount: 8,
arrowAutoHide: false,
},
};
/**
* Carousel with custom gap spacing (2rem).
* Demonstrates configurable spacing between items.
*/
export const CustomGap: Story = {
args: {
itemCount: 6,
gap: '2rem',
},
};

View File

@@ -0,0 +1,63 @@
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
/**
* Quote card component for Storybook carousel examples.
* Displays a quote with author in a styled card matching the Figma design.
*/
@Component({
selector: 'quote-card',
template: `
<div class="quote-card">
<div class="quote-card__content">
<p class="quote-card__quote isa-text-body-1-regular text-isa-neutral-900">
"{{ quote() }}"
</p>
@if (author()) {
<p class="quote-card__author isa-text-body-2-bold text-isa-neutral-700">
— {{ author() }}
</p>
}
</div>
</div>
`,
styles: [`
.quote-card {
background: white;
border-radius: 16px;
padding: 20px 22px;
min-width: 334px;
max-width: 334px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
transition: box-shadow 0.2s ease;
}
.quote-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.quote-card__content {
display: flex;
flex-direction: column;
gap: 12px;
}
.quote-card__quote {
font-style: italic;
line-height: 1.5;
margin: 0;
}
.quote-card__author {
margin: 0;
font-style: normal;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class QuoteCardComponent {
quote = input.required<string>();
author = input<string>('');
}

View File

@@ -1,11 +1,11 @@
# Library Reference Guide
> **Last Updated:** 2025-10-22
> **Last Updated:** 2025-10-27
> **Angular Version:** 20.1.2
> **Nx Version:** 21.3.2
> **Total Libraries:** 61
> **Total Libraries:** 62
All 61 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
All 62 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
@@ -278,6 +278,11 @@ A comprehensive button component library for Angular applications providing five
**Location:** `libs/ui/buttons/`
### `@isa/ui/carousel`
A horizontal scroll container component with left/right navigation arrows, responsive behavior, keyboard support, and auto-hide functionality for Angular applications.
**Location:** `libs/ui/carousel/`
### `@isa/ui/datepicker`
A comprehensive date range picker component library for Angular applications with calendar and month/year selection views, form integration, and robust validation.

281
libs/ui/carousel/README.md Normal file
View File

@@ -0,0 +1,281 @@
# @isa/ui/carousel
A horizontal scroll container component with left/right navigation arrows, responsive behavior, keyboard support, and auto-hide functionality.
## Features
-**Smooth horizontal scrolling** with viewport-width scroll amount
-**Navigation arrows** with auto-hide on hover (configurable)
-**Keyboard navigation** (Arrow Left/Right when focused)
-**Responsive design** with native touch gesture support
-**Disabled states** when at scroll boundaries
-**Touch gesture support** (native browser behavior)
-**Content projection** for flexible item rendering
-**Angular signals** for reactive state management
## Installation
The carousel library is part of the ISA-Frontend monorepo. Import it using the path alias:
```typescript
import { CarouselComponent } from '@isa/ui/carousel';
```
## Basic Usage
```typescript
import { Component } from '@angular/core';
import { CarouselComponent } from '@isa/ui/carousel';
@Component({
selector: 'app-example',
template: `
<ui-carousel>
@for (item of items; track item.id) {
<div class="card">{{ item.name }}</div>
}
</ui-carousel>
`,
imports: [CarouselComponent],
})
export class ExampleComponent {
items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
];
}
```
## API Reference
### Component Selector
`ui-carousel`
### Input Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `gap` | `string` | `'1rem'` | CSS gap value for spacing between carousel items (e.g., `'1rem'`, `'2rem'`, `'0.5rem'`). |
| `arrowAutoHide` | `boolean` | `true` | Whether to auto-hide arrows until the carousel is hovered or focused. When `true`, arrows fade in on hover/focus. |
### Methods
| Method | Parameters | Description |
|--------|------------|-------------|
| `scrollToPrevious()` | `event?: Event` | Scrolls to the previous set of items (left). Scrolls by viewport width. |
| `scrollToNext()` | `event?: Event` | Scrolls to the next set of items (right). Scrolls by viewport width. |
### Keyboard Navigation
| Key | Action |
|-----|--------|
| `Arrow Left` | Scroll to previous items |
| `Arrow Right` | Scroll to next items |
**Note:** The carousel must be focused (click or tab to it) for keyboard navigation to work.
## Usage Examples
### Example 1: Basic Carousel
```typescript
import { Component } from '@angular/core';
import { CarouselComponent } from '@isa/ui/carousel';
@Component({
template: `
<ui-carousel>
<div class="card">Card 1</div>
<div class="card">Card 2</div>
<div class="card">Card 3</div>
</ui-carousel>
`,
imports: [CarouselComponent],
})
export class BasicCarouselExample {}
```
### Example 2: Always Show Arrows
```typescript
import { Component } from '@angular/core';
import { CarouselComponent } from '@isa/ui/carousel';
@Component({
template: `
<ui-carousel [arrowAutoHide]="false">
@for (product of products; track product.id) {
<product-card [product]="product"></product-card>
}
</ui-carousel>
`,
imports: [CarouselComponent],
})
export class AlwaysShowArrowsExample {
products = [/* ... */];
}
```
### Example 3: Custom Gap
```typescript
import { Component } from '@angular/core';
import { CarouselComponent } from '@isa/ui/carousel';
@Component({
template: `
<ui-carousel [gap]="'2rem'">
@for (card of cards; track card.id) {
<div class="wide-card">{{ card.content }}</div>
}
</ui-carousel>
`,
imports: [CarouselComponent],
})
export class CustomGapExample {
cards = [/* ... */];
}
```
## Styling
### CSS Classes
The carousel component uses the following CSS classes:
| Class | Element | Description |
|-------|---------|-------------|
| `.ui-carousel` | Host element | Main container with focus styles |
| `.ui-carousel--auto-hide` | Host element | Applied when `arrowAutoHide` is `true` |
| `.ui-carousel__wrapper` | Wrapper | Contains buttons and scroll container |
| `.ui-carousel__container` | Scroll container | Horizontal scroll area with hidden scrollbar |
| `.ui-carousel__button` | Navigation button | Left/right arrow buttons |
| `.ui-carousel__button--left` | Left button | Positioned on the left side |
| `.ui-carousel__button--right` | Right button | Positioned on the right side |
### Custom Styling
You can override the default styles using CSS:
```scss
// Custom arrow button colors
.ui-carousel__button {
background-color: #your-color;
border-color: #your-border-color;
color: #your-icon-color;
&:hover {
background-color: #your-hover-color;
}
}
// Custom focus outline
.ui-carousel:focus {
outline-color: #your-focus-color;
}
// Custom scroll container
.ui-carousel__container {
// Add custom styles
}
```
## Accessibility
### Keyboard Navigation
- The carousel is focusable with `tabindex="0"`
- Arrow Left/Right keys scroll the carousel when focused
- Focus outline visible for keyboard users (`:focus-visible`)
### ARIA Attributes
- Navigation buttons have `aria-label` attributes:
- Left button: `"Previous"`
- Right button: `"Next"`
- Buttons are properly marked with `type="button"` to prevent form submission
### E2E Testing Attributes
All interactive elements include proper data attributes for automated testing:
```html
<button data-what="button" data-which="carousel-previous">...</button>
<button data-what="button" data-which="carousel-next">...</button>
```
## Behavior
### Scroll Logic
- **Scroll Amount**: Scrolls by the viewport width of the container
- **Smooth Scrolling**: Uses CSS `scroll-behavior: smooth` for animated transitions
- **Boundary Detection**: Automatically disables arrows when at the start/end of content
- **Responsive**: Arrows always show when content is scrollable, on all screen sizes
### Auto-Hide Arrows
When `arrowAutoHide` is `true`:
- Arrows are invisible by default (`opacity: 0`)
- Arrows fade in on carousel hover or focus
- Smooth CSS transitions for fade effects
### Touch Gestures
The carousel supports native touch gestures on mobile/tablet devices:
- Swipe left/right to scroll
- No custom JavaScript needed (browser default behavior)
## Browser Compatibility
- Modern browsers with CSS Grid and Flexbox support
- Native smooth scrolling support
- ResizeObserver API for content change detection
## Performance
- **OnPush Change Detection**: Optimized for minimal re-renders
- **Angular Signals**: Fine-grained reactivity with automatic dependency tracking
- **Native Scroll**: Leverages browser's native scroll performance
- **Cleanup**: Automatic event listener and observer cleanup on destroy
## Testing
Run unit tests:
```bash
npx nx test ui-carousel --skip-nx-cache
```
The carousel component includes comprehensive tests for:
- Component creation and default values
- Input property changes
- CSS class application
- Content projection
- Keyboard navigation
- Navigation button rendering
- E2E data attributes
## Related Components
- `@isa/ui/buttons` - Button components used for navigation arrows
- `@isa/icons` - Icon library for arrow icons
## Contributing
When contributing to this component:
1. Follow the ISA-Frontend coding conventions
2. Use Angular signals for reactive state
3. Include E2E data attributes (`data-what`, `data-which`)
4. Write comprehensive Vitest tests
5. Update this README for API changes
## License
Internal ISA-Frontend library.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ui',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ui',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "ui-carousel",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/carousel/src",
"prefix": "ui",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/ui/carousel"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/carousel.component';

View File

@@ -0,0 +1,73 @@
.ui-carousel {
position: relative;
width: 100%;
// Focus outline for keyboard navigation
&:focus {
outline: 0.125rem solid var(--isa-accent-primary, #354acb);
outline-offset: 0.125rem;
}
&:focus:not(:focus-visible) {
outline: none;
}
}
.ui-carousel__wrapper {
position: relative;
width: 100%;
display: flex;
align-items: center;
overflow: visible; // Allow items to overflow and be visible at edges
}
.ui-carousel__container {
display: flex;
overflow-x: auto;
overflow-y: visible; // Allow vertical overflow to be visible
scroll-behavior: smooth;
width: 100%;
// Hide scrollbar while maintaining scroll functionality
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge
&::-webkit-scrollbar {
display: none; // Chrome/Safari
}
// Enable touch scrolling on mobile
-webkit-overflow-scrolling: touch;
}
.ui-carousel__button {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
transition: opacity 0.3s ease;
// Left button positioning
&--left {
left: 0;
}
// Right button positioning
&--right {
right: 0;
}
}
// Auto-hide arrows variant
.ui-carousel--auto-hide {
.ui-carousel__button {
opacity: 0;
pointer-events: none;
}
&:hover .ui-carousel__button,
&:focus-within .ui-carousel__button {
opacity: 1;
pointer-events: auto;
}
}

View File

@@ -0,0 +1,39 @@
<div class="ui-carousel__wrapper">
@if (showLeftArrow()) {
<button
uiIconButton
class="ui-carousel__button ui-carousel__button--left"
type="button"
name="isaActionChevronLeft"
size="large"
color="tertiary"
data-what="button"
data-which="carousel-previous"
(click)="scrollToPrevious()"
[attr.aria-label]="'Previous'"
></button>
}
<div
#scrollContainer
class="ui-carousel__container"
[style.gap]="gap()"
>
<ng-content></ng-content>
</div>
@if (showRightArrow()) {
<button
uiIconButton
class="ui-carousel__button ui-carousel__button--right"
type="button"
name="isaActionChevronRight"
size="large"
color="tertiary"
data-what="button"
data-which="carousel-next"
(click)="scrollToNext()"
[attr.aria-label]="'Next'"
></button>
}
</div>

View File

@@ -0,0 +1,176 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { CarouselComponent } from './carousel.component';
// Test host component with mock items
@Component({
selector: 'ui-test-host',
template: `
<ui-carousel
[gap]="gap()"
[arrowAutoHide]="arrowAutoHide()"
>
@for (item of items(); track item) {
<div class="test-item" [style.width.px]="300">{{ item }}</div>
}
</ui-carousel>
`,
imports: [CarouselComponent],
})
class TestHostComponent {
items = signal(['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']);
gap = signal('1rem');
arrowAutoHide = signal(true);
}
describe('CarouselComponent', () => {
let component: CarouselComponent;
let fixture: ComponentFixture<CarouselComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CarouselComponent],
}).compileComponents();
fixture = TestBed.createComponent(CarouselComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default input values', () => {
expect(component.gap()).toBe('1rem');
expect(component.arrowAutoHide()).toBe(true);
});
it('should apply auto-hide class when arrowAutoHide is true', () => {
fixture.componentRef.setInput('arrowAutoHide', true);
fixture.detectChanges();
const hostElement = fixture.nativeElement as HTMLElement;
expect(hostElement.classList.contains('ui-carousel--auto-hide')).toBe(true);
});
it('should not apply auto-hide class when arrowAutoHide is false', () => {
fixture.componentRef.setInput('arrowAutoHide', false);
fixture.detectChanges();
const hostElement = fixture.nativeElement as HTMLElement;
expect(hostElement.classList.contains('ui-carousel--auto-hide')).toBe(false);
});
it('should have tabindex 0 for keyboard navigation', () => {
const hostElement = fixture.nativeElement as HTMLElement;
expect(hostElement.getAttribute('tabindex')).toBe('0');
});
});
describe('CarouselComponent with content', () => {
let hostComponent: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
hostComponent = fixture.componentInstance;
fixture.detectChanges();
});
it('should render projected content', () => {
const items = fixture.nativeElement.querySelectorAll('.test-item');
expect(items.length).toBe(5);
expect(items[0].textContent.trim()).toBe('Item 1');
});
it('should apply gap style to scroll container', () => {
hostComponent.gap.set('2rem');
fixture.detectChanges();
const container = fixture.nativeElement.querySelector('.ui-carousel__container');
expect(container.style.gap).toBe('2rem');
});
it('should show navigation buttons with proper data attributes', () => {
const buttons = fixture.nativeElement.querySelectorAll('.ui-carousel__button');
if (buttons.length > 0) {
const leftButton = fixture.nativeElement.querySelector('[data-which="carousel-previous"]');
const rightButton = fixture.nativeElement.querySelector('[data-which="carousel-next"]');
if (leftButton) {
expect(leftButton.getAttribute('data-what')).toBe('button');
expect(leftButton.getAttribute('aria-label')).toBe('Previous');
}
if (rightButton) {
expect(rightButton.getAttribute('data-what')).toBe('button');
expect(rightButton.getAttribute('aria-label')).toBe('Next');
}
}
});
it('should use ui-icon-button directive for navigation buttons', () => {
const buttons = fixture.nativeElement.querySelectorAll('.ui-carousel__button');
if (buttons.length > 0) {
buttons.forEach((button: Element) => {
expect(button.hasAttribute('uiiconbutton')).toBe(true);
});
}
});
it('should have scroll container with proper classes', () => {
const container = fixture.nativeElement.querySelector('.ui-carousel__container');
expect(container).toBeTruthy();
expect(container.classList.contains('ui-carousel__container')).toBe(true);
});
it('should change arrowAutoHide input', () => {
hostComponent.arrowAutoHide.set(false);
fixture.detectChanges();
const carousel = fixture.debugElement.children[0].componentInstance as CarouselComponent;
expect(carousel.arrowAutoHide()).toBe(false);
});
});
describe('CarouselComponent keyboard navigation', () => {
let component: CarouselComponent;
let fixture: ComponentFixture<CarouselComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CarouselComponent],
}).compileComponents();
fixture = TestBed.createComponent(CarouselComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should call scrollToPrevious on ArrowLeft keydown', () => {
const spy = vi.spyOn(component, 'scrollToPrevious');
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
const hostElement = fixture.nativeElement as HTMLElement;
hostElement.dispatchEvent(event);
expect(spy).toHaveBeenCalled();
});
it('should call scrollToNext on ArrowRight keydown', () => {
const spy = vi.spyOn(component, 'scrollToNext');
const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
const hostElement = fixture.nativeElement as HTMLElement;
hostElement.dispatchEvent(event);
expect(spy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,180 @@
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
input,
computed,
signal,
viewChild,
ElementRef,
effect,
} from '@angular/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft, isaActionChevronRight } from '@isa/icons';
@Component({
selector: 'ui-carousel',
imports: [IconButtonComponent],
templateUrl: './carousel.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideIcons({ isaActionChevronLeft, isaActionChevronRight })],
host: {
'[class]': '["ui-carousel", arrowAutoHideClass()]',
'[tabindex]': '0',
'(keydown.ArrowLeft)': 'scrollToPrevious($event)',
'(keydown.ArrowRight)': 'scrollToNext($event)',
},
})
export class CarouselComponent {
// Input signals
gap = input<string>('1rem');
arrowAutoHide = input<boolean>(true);
// View child for scroll container
scrollContainer =
viewChild.required<ElementRef<HTMLDivElement>>('scrollContainer');
// Internal state signals
canScrollLeft = signal(false);
canScrollRight = signal(false);
// Computed signals
arrowAutoHideClass = computed(() =>
this.arrowAutoHide() ? 'ui-carousel--auto-hide' : '',
);
showLeftArrow = computed(() => this.canScrollLeft());
showRightArrow = computed(() => this.canScrollRight());
constructor() {
// Update scroll state whenever the scroll container changes
effect(() => {
const container = this.scrollContainer()?.nativeElement;
if (container) {
this.updateScrollState();
// Add scroll event listener
const handleScroll = () => this.updateScrollState();
container.addEventListener('scroll', handleScroll);
// Add resize observer to detect content changes
const resizeObserver = new ResizeObserver(() => {
this.updateScrollState();
});
resizeObserver.observe(container);
// Cleanup on destroy
return () => {
container.removeEventListener('scroll', handleScroll);
resizeObserver.disconnect();
};
}
return;
});
}
/**
* Update the scroll state (can scroll left/right)
*/
private updateScrollState(): void {
const container = this.scrollContainer()?.nativeElement;
if (!container) return;
const { scrollLeft, scrollWidth, clientWidth } = container;
// Check if we can scroll left (not at the start)
this.canScrollLeft.set(scrollLeft > 0);
// Check if we can scroll right (not at the end)
// Add a small threshold (1px) to account for rounding errors
this.canScrollRight.set(scrollLeft < scrollWidth - clientWidth - 1);
}
/**
* Calculate the scroll amount based on fully visible items
*/
private calculateScrollAmount(): number {
const container = this.scrollContainer()?.nativeElement;
if (!container) return 0;
const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) return container.clientWidth;
const containerRect = container.getBoundingClientRect();
const containerLeft = containerRect.left;
const containerRight = containerRect.right;
// Count fully visible items and get the first one
let firstFullyVisibleItem: HTMLElement | null = null;
let fullyVisibleCount = 0;
for (const child of children) {
const childRect = child.getBoundingClientRect();
const isFullyVisible =
childRect.left >= containerLeft - 1 &&
childRect.right <= containerRight + 1;
if (isFullyVisible) {
if (!firstFullyVisibleItem) {
firstFullyVisibleItem = child as HTMLElement;
}
fullyVisibleCount++;
}
}
// If we have fully visible items, use their width as scroll amount
if (fullyVisibleCount >= 1 && firstFullyVisibleItem) {
// Get the next sibling to calculate the width including gap
const nextSibling =
firstFullyVisibleItem.nextElementSibling as HTMLElement;
if (nextSibling) {
const firstRect = firstFullyVisibleItem.getBoundingClientRect();
const nextRect = nextSibling.getBoundingClientRect();
const itemWidthWithGap = nextRect.left - firstRect.left;
// Scroll by the width of fully visible items
const scrollAmount = itemWidthWithGap * fullyVisibleCount;
return scrollAmount;
}
// Fallback: just use the first item's width
return firstFullyVisibleItem.getBoundingClientRect().width;
}
// Fallback: scroll by container width
return container.clientWidth;
}
/**
* Scroll to the previous set of items (left)
*/
scrollToPrevious(event?: Event): void {
event?.preventDefault();
const container = this.scrollContainer()?.nativeElement;
if (!container) return;
const scrollAmount = this.calculateScrollAmount();
container.scrollBy({
left: -scrollAmount,
behavior: 'smooth',
});
}
/**
* Scroll to the next set of items (right)
*/
scrollToNext(event?: Event): void {
event?.preventDefault();
const container = this.scrollContainer()?.nativeElement;
if (!container) return;
const scrollAmount = this.calculateScrollAmount();
container.scrollBy({
left: scrollAmount,
behavior: 'smooth',
});
}
}

View File

@@ -0,0 +1,24 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);
// Mock ResizeObserver for tests
global.ResizeObserver = class ResizeObserver {
// eslint-disable-next-line @typescript-eslint/no-empty-function
observe() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
unobserve() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
disconnect() {}
};

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,27 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/ui/carousel',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/ui/carousel',
provider: 'v8' as const,
},
},
}));

76791
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +1,131 @@
{
"name": "hima",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "nx serve isa-app --ssl",
"pretest": "npx trash-cli testresults",
"test": "npx nx run-many --tuiAutoExit true -t test --exclude isa-app",
"ci": "npx nx run-many -t test --exclude isa-app -c ci --tuiAutoExit true",
"build": "nx build isa-app --configuration=development",
"build-prod": "nx build isa-app --configuration=production",
"lint": "nx lint",
"e2e": "nx e2e",
"generate:swagger": "nx run-many -t generate -p tag:generated,swagger",
"fix:files:swagger": "node ./tools/fix-files.js generated/swagger",
"prettier": "prettier --write .",
"pretty-quick": "pretty-quick --staged",
"prepare": "husky",
"storybook": "npx nx run isa-app:storybook",
"docs:generate": "node tools/generate-library-reference.js"
},
"private": true,
"dependencies": {
"@angular-architects/ngrx-toolkit": "^20.4.0",
"@angular/animations": "20.3.6",
"@angular/cdk": "20.2.9",
"@angular/common": "20.3.6",
"@angular/compiler": "20.3.6",
"@angular/core": "20.3.6",
"@angular/forms": "20.3.6",
"@angular/localize": "20.3.6",
"@angular/platform-browser": "20.3.6",
"@angular/platform-browser-dynamic": "20.3.6",
"@angular/router": "20.3.6",
"@angular/service-worker": "20.3.6",
"@microsoft/signalr": "^8.0.7",
"@ng-icons/core": "32.2.0",
"@ng-icons/material-icons": "32.2.0",
"@ngrx/component-store": "^20.0.0",
"@ngrx/effects": "^20.0.0",
"@ngrx/entity": "^20.0.0",
"@ngrx/operators": "^20.0.0",
"@ngrx/signals": "^20.0.0",
"@ngrx/store": "^20.0.0",
"@ngrx/store-devtools": "^20.0.0",
"angular-oauth2-oidc": "^20.0.2",
"angular-oauth2-oidc-jwks": "^20.0.0",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"ng2-pdf-viewer": "^10.4.0",
"ngx-matomo-client": "^8.0.0",
"parse-duration": "^2.1.3",
"rxjs": "~7.8.2",
"scandit-web-datacapture-barcode": "^6.28.1",
"scandit-web-datacapture-core": "^6.28.1",
"tslib": "^2.3.0",
"uuid": "^8.3.2",
"zod": "^3.24.2",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "1.21.3",
"@analogjs/vitest-angular": "1.21.3",
"@angular-devkit/build-angular": "^20.3.6",
"@angular-devkit/core": "20.3.6",
"@angular-devkit/schematics": "20.3.6",
"@angular/build": "^20.3.6",
"@angular/cli": "^20.3.6",
"@angular/compiler-cli": "20.3.6",
"@angular/language-service": "20.3.6",
"@angular/pwa": "20.3.6",
"@eslint/js": "^9.8.0",
"@ngneat/spectator": "22.0.0",
"@nx/angular": "21.3.2",
"@nx/eslint": "21.3.2",
"@nx/eslint-plugin": "21.3.2",
"@nx/jest": "21.3.2",
"@nx/js": "21.3.2",
"@nx/storybook": "21.3.2",
"@nx/vite": "21.3.2",
"@nx/web": "21.3.2",
"@nx/workspace": "21.3.2",
"@schematics/angular": "20.3.6",
"@storybook/addon-docs": "^9.0.11",
"@storybook/angular": "^9.0.5",
"@swc-node/register": "1.10.10",
"@swc/core": "1.12.1",
"@swc/helpers": "0.5.17",
"@types/jest": "30.0.0",
"@types/lodash": "^4.17.16",
"@types/node": "18.16.9",
"@types/uuid": "^10.0.0",
"@typescript-eslint/utils": "^8.33.1",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"angular-eslint": "20.4.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "14.6.0",
"jiti": "2.4.2",
"jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.14.0",
"ng-packagr": "20.3.0",
"ng-swagger-gen": "^2.3.1",
"nx": "21.3.2",
"postcss": "^8.5.3",
"postcss-url": "~10.1.3",
"prettier": "^3.5.2",
"pretty-quick": "~4.0.0",
"storybook": "^9.0.5",
"tailwindcss": "^3.4.14",
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",
"typescript": "5.8.3",
"typescript-eslint": "^8.33.1",
"vite": "6.3.5",
"vitest": "^3.1.1"
},
"engines": {
"node": ">=22.12.0 <23.0.0",
"npm": ">=11.6.0 <11.7.0"
}
}
{
"name": "hima",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "nx serve isa-app --ssl",
"pretest": "npx trash-cli testresults",
"test": "npx nx run-many --tuiAutoExit true -t test --exclude isa-app",
"ci": "npx nx run-many -t test --exclude isa-app -c ci --tuiAutoExit true",
"build": "nx build isa-app --configuration=development",
"build-prod": "nx build isa-app --configuration=production",
"lint": "nx lint",
"e2e": "nx e2e",
"generate:swagger": "nx run-many -t generate -p tag:generated,swagger",
"fix:files:swagger": "node ./tools/fix-files.js generated/swagger",
"prettier": "prettier --write .",
"pretty-quick": "pretty-quick --staged",
"prepare": "husky",
"storybook": "npx nx run isa-app:storybook",
"docs:generate": "node tools/generate-library-reference.js"
},
"private": true,
"dependencies": {
"@angular-architects/ngrx-toolkit": "^20.4.0",
"@angular/animations": "20.3.6",
"@angular/cdk": "20.2.9",
"@angular/common": "20.3.6",
"@angular/compiler": "20.3.6",
"@angular/core": "20.3.6",
"@angular/forms": "20.3.6",
"@angular/localize": "20.3.6",
"@angular/platform-browser": "20.3.6",
"@angular/platform-browser-dynamic": "20.3.6",
"@angular/router": "20.3.6",
"@angular/service-worker": "20.3.6",
"@microsoft/signalr": "^8.0.7",
"@ng-icons/core": "32.2.0",
"@ng-icons/material-icons": "32.2.0",
"@ngrx/component-store": "^20.0.0",
"@ngrx/effects": "^20.0.0",
"@ngrx/entity": "^20.0.0",
"@ngrx/operators": "^20.0.0",
"@ngrx/signals": "^20.0.0",
"@ngrx/store": "^20.0.0",
"@ngrx/store-devtools": "^20.0.0",
"angular-oauth2-oidc": "^20.0.2",
"angular-oauth2-oidc-jwks": "^20.0.0",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"ng2-pdf-viewer": "^10.4.0",
"ngx-matomo-client": "^8.0.0",
"parse-duration": "^2.1.3",
"rxjs": "~7.8.2",
"scandit-web-datacapture-barcode": "^6.28.1",
"scandit-web-datacapture-core": "^6.28.1",
"tslib": "^2.3.0",
"uuid": "^8.3.2",
"zod": "^3.24.2",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "1.21.3",
"@analogjs/vitest-angular": "1.21.3",
"@angular-devkit/build-angular": "^20.3.6",
"@angular-devkit/core": "20.3.6",
"@angular-devkit/schematics": "20.3.6",
"@angular/build": "^20.3.6",
"@angular/cli": "^20.3.6",
"@angular/compiler-cli": "20.3.6",
"@angular/language-service": "20.3.6",
"@angular/pwa": "20.3.6",
"@eslint/js": "^9.8.0",
"@ngneat/spectator": "22.0.0",
"@nx/angular": "21.3.2",
"@nx/eslint": "21.3.2",
"@nx/eslint-plugin": "21.3.2",
"@nx/jest": "21.3.2",
"@nx/js": "21.3.2",
"@nx/storybook": "21.3.2",
"@nx/vite": "21.3.2",
"@nx/web": "21.3.2",
"@nx/workspace": "21.3.2",
"@schematics/angular": "20.3.6",
"@storybook/addon-docs": "^9.0.11",
"@storybook/angular": "^9.0.5",
"@swc-node/register": "1.10.10",
"@swc/core": "1.12.1",
"@swc/helpers": "0.5.17",
"@types/jest": "30.0.0",
"@types/lodash": "^4.17.16",
"@types/node": "18.16.9",
"@types/uuid": "^10.0.0",
"@typescript-eslint/utils": "^8.33.1",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"angular-eslint": "20.4.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "14.6.0",
"jiti": "2.4.2",
"jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.14.0",
"ng-packagr": "20.3.0",
"ng-swagger-gen": "^2.3.1",
"nx": "21.3.2",
"postcss": "^8.5.3",
"postcss-url": "~10.1.3",
"prettier": "^3.5.2",
"pretty-quick": "~4.0.0",
"storybook": "^9.0.5",
"tailwindcss": "^3.4.14",
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",
"typescript": "5.8.3",
"typescript-eslint": "^8.33.1",
"vite": "6.3.5",
"vitest": "^3.1.1"
},
"engines": {
"node": ">=22.12.0 <23.0.0",
"npm": ">=11.6.0 <11.7.0"
}
}

View File

@@ -125,6 +125,7 @@
"@isa/shared/scanner": ["libs/shared/scanner/src/index.ts"],
"@isa/ui/bullet-list": ["libs/ui/bullet-list/src/index.ts"],
"@isa/ui/buttons": ["libs/ui/buttons/src/index.ts"],
"@isa/ui/carousel": ["libs/ui/carousel/src/index.ts"],
"@isa/ui/datepicker": ["libs/ui/datepicker/src/index.ts"],
"@isa/ui/dialog": ["libs/ui/dialog/src/index.ts"],
"@isa/ui/empty-state": ["libs/ui/empty-state/src/index.ts"],