mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 1967: Reward Shopping Cart Implementation
This commit is contained in:
committed by
Nino Righi
parent
d761704dc4
commit
f15848d5c0
312
libs/shared/quantity-control/README.md
Normal file
312
libs/shared/quantity-control/README.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Quantity Control
|
||||
|
||||
An accessible, feature-rich Angular quantity selector component with dropdown presets and manual input mode.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Dropdown with presets** - Quick selection from predefined values
|
||||
- ✅ **Edit mode** - Manual input for values beyond presets
|
||||
- ✅ **Smart logic** - Automatically shows/hides Edit based on constraints
|
||||
- ✅ **Flexible range** - Start from any value (0, 1, or custom)
|
||||
- ✅ **Full accessibility** - WCAG 2.1 AA compliant with screen reader support
|
||||
- ✅ **Keyboard navigation** - Arrow keys, Home, End, Enter, Escape
|
||||
- ✅ **Form integration** - Implements `ControlValueAccessor`
|
||||
- ✅ **Type-safe** - Full TypeScript support with proper validation
|
||||
|
||||
## Installation
|
||||
|
||||
```typescript
|
||||
import { QuantityControlComponent } from '@isa/shared/quantity-control';
|
||||
|
||||
@Component({
|
||||
// ...
|
||||
imports: [QuantityControlComponent],
|
||||
})
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Standalone
|
||||
|
||||
```html
|
||||
<shared-quantity-control [value]="quantity" />
|
||||
```
|
||||
|
||||
### With Reactive Forms
|
||||
|
||||
```typescript
|
||||
export class MyComponent {
|
||||
quantityControl = new FormControl(1);
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<shared-quantity-control [formControl]="quantityControl" />
|
||||
```
|
||||
|
||||
### With Template-Driven Forms
|
||||
|
||||
```html
|
||||
<shared-quantity-control [(ngModel)]="quantity" />
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `value` | `model<number>` | `1` | Current quantity value (two-way binding) |
|
||||
| `disabled` | `model<boolean>` | `false` | Whether the control is disabled |
|
||||
| `min` | `number` | `1` | Minimum selectable value (starting point) |
|
||||
| `max` | `number \| undefined` | `undefined` | Maximum selectable value (e.g., stock available) |
|
||||
| `presetLimit` | `number` | `10` | Number of preset options before requiring Edit |
|
||||
| `ariaLabel` | `string` | `undefined` | Custom ARIA label for accessibility |
|
||||
|
||||
### How It Works
|
||||
|
||||
**Preset options generated:** `min` to `(min + presetLimit - 1)`
|
||||
|
||||
**Edit option shown when:**
|
||||
- `max` is `undefined` (unlimited), OR
|
||||
- `max > (min + presetLimit - 1)` (stock exceeds presets)
|
||||
|
||||
## Examples
|
||||
|
||||
### Standard Use Case (1-10 with unlimited)
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[(value)]="quantity"
|
||||
[min]="1"
|
||||
[presetLimit]="10"
|
||||
/>
|
||||
```
|
||||
**Dropdown:** 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, Edit ✅
|
||||
|
||||
---
|
||||
|
||||
### Start From Zero
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[(value)]="quantity"
|
||||
[min]="0"
|
||||
[presetLimit]="10"
|
||||
/>
|
||||
```
|
||||
**Dropdown:** 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, Edit ✅
|
||||
|
||||
---
|
||||
|
||||
### Limited Stock (No Edit)
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[(value)]="quantity"
|
||||
[min]="1"
|
||||
[max]="5"
|
||||
[presetLimit]="10"
|
||||
/>
|
||||
```
|
||||
**Dropdown:** 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 (No Edit - max is 5)
|
||||
|
||||
---
|
||||
|
||||
### High Stock (With Edit)
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[(value)]="quantity"
|
||||
[min]="1"
|
||||
[max]="50"
|
||||
[presetLimit]="20"
|
||||
/>
|
||||
```
|
||||
**Dropdown:** 1, 2, 3, ... 20, Edit ✅ (allows 21-50)
|
||||
|
||||
---
|
||||
|
||||
### Custom Range (5-15)
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[(value)]="quantity"
|
||||
[min]="5"
|
||||
[presetLimit]="11"
|
||||
[max]="15"
|
||||
/>
|
||||
```
|
||||
**Dropdown:** 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 (No Edit)
|
||||
|
||||
---
|
||||
|
||||
### With Reactive Forms
|
||||
|
||||
```typescript
|
||||
export class ShoppingCartComponent {
|
||||
quantityControl = new FormControl(1, [
|
||||
Validators.min(1),
|
||||
Validators.max(99)
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[formControl]="quantityControl"
|
||||
[min]="1"
|
||||
[max]="99"
|
||||
[presetLimit]="10"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Disabled State
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[(value)]="quantity"
|
||||
[disabled]="true"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Custom ARIA Label
|
||||
|
||||
```html
|
||||
<shared-quantity-control
|
||||
[(value)]="quantity"
|
||||
[ariaLabel]="'Select number of items to purchase'"
|
||||
/>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
The component implements the **ARIA combobox pattern** and is fully accessible:
|
||||
|
||||
- ✅ **Keyboard Navigation:**
|
||||
- `Space/Enter` - Open dropdown
|
||||
- `Arrow Up/Down` - Navigate options
|
||||
- `Home/End` - Jump to first/last option
|
||||
- `Enter/Space` - Select option
|
||||
- `Escape` - Close dropdown or cancel edit
|
||||
|
||||
- ✅ **ARIA Attributes:**
|
||||
- `role="combobox"` on host
|
||||
- `role="listbox"` on dropdown
|
||||
- `role="option"` on each item
|
||||
- `aria-expanded`, `aria-haspopup`, `aria-controls`
|
||||
- `aria-activedescendant` for keyboard focus
|
||||
- `aria-selected` for current value
|
||||
|
||||
- ✅ **Screen Reader Support:**
|
||||
- Value changes announced
|
||||
- Edit mode transitions announced
|
||||
- Validation errors announced
|
||||
|
||||
- ✅ **E2E Testing:**
|
||||
- `data-what="quantity-control"` on button
|
||||
- `data-what="quantity-control-option"` on options
|
||||
- `data-which` attributes with values
|
||||
|
||||
## Validation
|
||||
|
||||
### Automatic Validation
|
||||
|
||||
The component validates input in Edit mode:
|
||||
|
||||
```typescript
|
||||
// If min=1, entering 0 shows:
|
||||
"Invalid quantity. Please enter a number greater than or equal to 1."
|
||||
|
||||
// If max=50, entering 100 shows:
|
||||
"Invalid quantity. Maximum available is 50."
|
||||
```
|
||||
|
||||
### Form Validators
|
||||
|
||||
Use standard Angular validators with the form control:
|
||||
|
||||
```typescript
|
||||
quantityControl = new FormControl(1, [
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(999),
|
||||
]);
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
### Dropdown
|
||||
|
||||
- Click button or press `Space`/`Enter` to open
|
||||
- Click option to select
|
||||
- Click outside (backdrop) to close
|
||||
- Press `Escape` to close
|
||||
|
||||
### Edit Mode
|
||||
|
||||
- Select "Edit" option from dropdown
|
||||
- Input field opens with current value pre-selected
|
||||
- Press `Enter` to save
|
||||
- Press `Escape` to cancel (reverts to original value)
|
||||
- Click outside (blur) to save
|
||||
|
||||
## Styling
|
||||
|
||||
The component uses scoped CSS with these CSS classes:
|
||||
|
||||
```css
|
||||
.quantity-control-button /* Main button */
|
||||
.quantity-control-value /* Value display */
|
||||
.quantity-control-input /* Edit mode input */
|
||||
.options-list /* Dropdown container */
|
||||
.quantity-control-option /* Each option */
|
||||
.quantity-control-option.active /* Keyboard focused */
|
||||
.quantity-control-option.selected /* Current value */
|
||||
```
|
||||
|
||||
### Host Classes
|
||||
|
||||
```css
|
||||
:host.disabled /* Disabled state */
|
||||
:host.open /* Dropdown open */
|
||||
:host.edit-mode /* Edit mode active */
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- ✅ **OnPush change detection** for optimal performance
|
||||
- ✅ **Signal-based reactivity** with computed values
|
||||
- ✅ **Efficient keyboard manager** from Angular CDK
|
||||
- ✅ **Automatic cleanup** on component destruction
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome/Edge (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
- Mobile browsers (iOS Safari, Chrome Android)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@angular/core` ^20.1.2
|
||||
- `@angular/forms` ^20.1.2
|
||||
- `@angular/cdk` ^20.1.2
|
||||
- `@ng-icons/core` (for chevron icons)
|
||||
|
||||
## Contributing
|
||||
|
||||
This component is part of the ISA Frontend monorepo. For changes:
|
||||
|
||||
1. Update component code
|
||||
2. Update tests (when available)
|
||||
3. Update Storybook stories
|
||||
4. Update this README if API changes
|
||||
|
||||
## License
|
||||
|
||||
Internal use only - ISA Frontend Project
|
||||
34
libs/shared/quantity-control/eslint.config.cjs
Normal file
34
libs/shared/quantity-control/eslint.config.cjs
Normal 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: 'shared',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'shared',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
30
libs/shared/quantity-control/project.json
Normal file
30
libs/shared/quantity-control/project.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "shared-quantity-control",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/quantity-control/src",
|
||||
"prefix": "shared",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": [
|
||||
"{options.reportsDirectory}"
|
||||
],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../coverage/libs/shared/quantity-control"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"mode": "run",
|
||||
"coverage": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
libs/shared/quantity-control/src/index.ts
Normal file
3
libs/shared/quantity-control/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './lib/quantity-control.component';
|
||||
export * from './lib/quantity-control-option.component';
|
||||
export * from './lib/quantity-control.types';
|
||||
@@ -0,0 +1,20 @@
|
||||
:host {
|
||||
@apply flex w-full min-h-12 flex-col justify-center items-center gap-2.5 select-none transition-colors duration-150 rounded-2xl cursor-pointer flex-shrink-0 isa-text-body-2-bold;
|
||||
}
|
||||
|
||||
:host.active,
|
||||
:host:hover {
|
||||
@apply bg-isa-neutral-300;
|
||||
}
|
||||
|
||||
:host.selected {
|
||||
@apply bg-isa-neutral-200 text-isa-accent-blue;
|
||||
}
|
||||
|
||||
:host.disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
:host.disabled:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<ng-content></ng-content>
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { QuantityControlOptionComponent } from './quantity-control-option.component';
|
||||
import { QuantityControlComponent } from './quantity-control.component';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
describe('QuantityControlOptionComponent', () => {
|
||||
let component: QuantityControlOptionComponent;
|
||||
let fixture: ComponentFixture<QuantityControlOptionComponent>;
|
||||
let mockParent: Partial<QuantityControlComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a mock parent component with only the necessary properties
|
||||
mockParent = {
|
||||
value: signal(1),
|
||||
selectValue: vi.fn(),
|
||||
enterEditMode: vi.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [QuantityControlOptionComponent],
|
||||
providers: [
|
||||
{ provide: QuantityControlComponent, useValue: mockParent },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(QuantityControlOptionComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// Set required inputs
|
||||
fixture.componentRef.setInput('id', 'option-1');
|
||||
fixture.componentRef.setInput('value', 1);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('Component Creation', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have id input', () => {
|
||||
expect(component.id()).toBe('option-1');
|
||||
});
|
||||
|
||||
it('should have value input', () => {
|
||||
expect(component.value()).toBe(1);
|
||||
});
|
||||
|
||||
it('should not be active by default', () => {
|
||||
expect(component.active()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be disabled by default', () => {
|
||||
expect(component.disabled).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selected State', () => {
|
||||
it('should be selected when value matches parent value', () => {
|
||||
mockParent.value!.set(1);
|
||||
|
||||
expect(component.selected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be selected when value does not match parent value', () => {
|
||||
mockParent.value!.set(5);
|
||||
|
||||
expect(component.selected()).toBe(false);
|
||||
});
|
||||
|
||||
it('should never be selected for edit option', () => {
|
||||
fixture.componentRef.setInput('value', 'edit');
|
||||
mockParent.value!.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active State', () => {
|
||||
it('should set active state when setActiveStyles is called', () => {
|
||||
component.setActiveStyles();
|
||||
|
||||
expect(component.active()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set inactive state when setInactiveStyles is called', () => {
|
||||
component.active.set(true);
|
||||
|
||||
component.setInactiveStyles();
|
||||
|
||||
expect(component.active()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSS Classes', () => {
|
||||
it('should return "active" class when active', () => {
|
||||
component.active.set(true);
|
||||
|
||||
expect(component.activeClass()).toBe('active');
|
||||
});
|
||||
|
||||
it('should return empty string when not active', () => {
|
||||
component.active.set(false);
|
||||
|
||||
expect(component.activeClass()).toBe('');
|
||||
});
|
||||
|
||||
it('should return "selected" class when selected', () => {
|
||||
mockParent.value!.set(1);
|
||||
|
||||
expect(component.selectedClass()).toBe('selected');
|
||||
});
|
||||
|
||||
it('should return empty string when not selected', () => {
|
||||
mockParent.value!.set(5);
|
||||
|
||||
expect(component.selectedClass()).toBe('');
|
||||
});
|
||||
|
||||
it('should return "disabled" class when disabled property is truthy', () => {
|
||||
// Create a fresh component instance to test the computed logic
|
||||
const testFixture = TestBed.createComponent(QuantityControlOptionComponent);
|
||||
const testComponent = testFixture.componentInstance;
|
||||
testFixture.componentRef.setInput('id', 'test');
|
||||
testFixture.componentRef.setInput('value', 1);
|
||||
testComponent.disabled = true;
|
||||
testFixture.detectChanges();
|
||||
|
||||
expect(testComponent.disabledClass()).toBe('disabled');
|
||||
});
|
||||
|
||||
it('should return empty string when disabled is falsy', () => {
|
||||
// Component is created with disabled undefined by default
|
||||
expect(component.disabledClass()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selection Behavior', () => {
|
||||
it('should call parent selectValue when selecting a number option', () => {
|
||||
component.select();
|
||||
|
||||
expect(mockParent.selectValue).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should call parent enterEditMode when selecting edit option', () => {
|
||||
fixture.componentRef.setInput('value', 'edit');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.select();
|
||||
|
||||
expect(mockParent.enterEditMode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not select when disabled', () => {
|
||||
component.disabled = true;
|
||||
|
||||
component.select();
|
||||
|
||||
expect(mockParent.selectValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Different Value Types', () => {
|
||||
it('should handle numeric values', () => {
|
||||
fixture.componentRef.setInput('value', 5);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.value()).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle edit value', () => {
|
||||
fixture.componentRef.setInput('value', 'edit');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.value()).toBe('edit');
|
||||
});
|
||||
|
||||
it('should handle zero value', () => {
|
||||
fixture.componentRef.setInput('value', 0);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.value()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Element Reference', () => {
|
||||
it('should have element reference', () => {
|
||||
expect(component.elementRef).toBeDefined();
|
||||
expect(component.elementRef.nativeElement).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Highlightable Interface', () => {
|
||||
it('should implement Highlightable interface', () => {
|
||||
expect(component.setActiveStyles).toBeDefined();
|
||||
expect(component.setInactiveStyles).toBeDefined();
|
||||
});
|
||||
|
||||
it('should toggle highlighting correctly', () => {
|
||||
component.setActiveStyles();
|
||||
expect(component.active()).toBe(true);
|
||||
|
||||
component.setInactiveStyles();
|
||||
expect(component.active()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Highlightable } from '@angular/cdk/a11y';
|
||||
import { QuantityControlComponent } from './quantity-control.component';
|
||||
|
||||
@Component({
|
||||
selector: 'shared-quantity-control-option',
|
||||
templateUrl: './quantity-control-option.component.html',
|
||||
styleUrls: ['./quantity-control-option.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [],
|
||||
host: {
|
||||
'[class]':
|
||||
'["quantity-control-option", activeClass(), selectedClass(), disabledClass()]',
|
||||
'role': 'option',
|
||||
'[id]': 'id()',
|
||||
'[attr.aria-selected]': 'selected()',
|
||||
'[attr.tabindex]': '-1',
|
||||
'[attr.data-what]': '"quantity-control-option"',
|
||||
'[attr.data-which]': 'value()',
|
||||
'(click)': 'select()',
|
||||
},
|
||||
})
|
||||
export class QuantityControlOptionComponent implements Highlightable {
|
||||
private host = inject(QuantityControlComponent);
|
||||
|
||||
/**
|
||||
* Reference to the host element for scrolling
|
||||
*/
|
||||
elementRef = inject(ElementRef<HTMLElement>);
|
||||
|
||||
/**
|
||||
* Unique ID for this option (for ARIA)
|
||||
*/
|
||||
id = input.required<string>();
|
||||
|
||||
/**
|
||||
* Option value - can be a number or 'edit'
|
||||
*/
|
||||
value = input.required<number | 'edit'>();
|
||||
|
||||
/**
|
||||
* Active state (keyboard highlight)
|
||||
*/
|
||||
active = signal(false);
|
||||
|
||||
/**
|
||||
* Disabled state
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this option is selected
|
||||
*/
|
||||
selected = computed(() => {
|
||||
const val = this.value();
|
||||
// 'edit' option is never selected
|
||||
if (val === 'edit') {
|
||||
return false;
|
||||
}
|
||||
return this.host.value() === val;
|
||||
});
|
||||
|
||||
/**
|
||||
* CSS class for active state
|
||||
*/
|
||||
activeClass = computed(() => (this.active() ? 'active' : ''));
|
||||
|
||||
/**
|
||||
* CSS class for selected state
|
||||
*/
|
||||
selectedClass = computed(() => (this.selected() ? 'selected' : ''));
|
||||
|
||||
/**
|
||||
* CSS class for disabled state
|
||||
*/
|
||||
disabledClass = computed(() => (this.disabled ? 'disabled' : ''));
|
||||
|
||||
setActiveStyles() {
|
||||
this.active.set(true);
|
||||
}
|
||||
|
||||
setInactiveStyles() {
|
||||
this.active.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle option selection
|
||||
*/
|
||||
select() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const val = this.value();
|
||||
if (val === 'edit') {
|
||||
// Open edit mode
|
||||
this.host.enterEditMode();
|
||||
} else {
|
||||
// Select the numeric value
|
||||
this.host.selectValue(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
:host {
|
||||
@apply inline-block relative focus:outline-none;
|
||||
}
|
||||
|
||||
.quantity-control__button {
|
||||
@apply flex items-center justify-between gap-2 px-4 py-2;
|
||||
}
|
||||
|
||||
.quantity-control__input {
|
||||
@apply bg-transparent focus:outline-none focus-visible:outline-none text-center px-2 py-2;
|
||||
}
|
||||
|
||||
.quantity-control__dropdown {
|
||||
@apply bg-isa-white list-none m-0 flex flex-col w-[6.0625rem] p-1 items-center max-h-[300px] overflow-y-auto rounded-[1.25rem] shadow-[0_0_16px_0_rgba(0,0,0,0.15)];
|
||||
}
|
||||
|
||||
/* Remove number input spinner buttons */
|
||||
input[type='number']::-webkit-outer-spin-button,
|
||||
input[type='number']::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type='number']::placeholder {
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!-- Button showing current value (hidden in edit mode) -->
|
||||
@if (!isEditMode()) {
|
||||
<button
|
||||
type="button"
|
||||
class="quantity-control__button"
|
||||
(click)="toggleDropdown()"
|
||||
[disabled]="disabled()"
|
||||
[attr.data-what]="'quantity-control'"
|
||||
[attr.data-which]="value()"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span>{{ value() }}</span>
|
||||
<ng-icon [name]="chevronIcon()" size="1.5rem"></ng-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Edit Mode Input (replaces button) -->
|
||||
@if (isEditMode()) {
|
||||
<input
|
||||
type="number"
|
||||
#editInput
|
||||
class="quantity-control__input"
|
||||
[value]="value()"
|
||||
[style.width.px]="buttonWidth()"
|
||||
(input)="onInputChange(editInput.value)"
|
||||
(blur)="exitEditMode(editInput.value)"
|
||||
(keydown.enter)="exitEditMode(editInput.value)"
|
||||
(keydown.escape)="cancelEditMode()"
|
||||
[min]="min()"
|
||||
[max]="max()"
|
||||
placeholder="Menge"
|
||||
[attr.data-what]="'quantity-control-input'"
|
||||
[disabled]="disabled()"
|
||||
uiTooltip
|
||||
[content]="validationMessage()"
|
||||
[triggerOn]="[]"
|
||||
variant="warning"
|
||||
#tooltipDir="uiTooltip"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Dropdown Overlay with options -->
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="cdkOverlayOrigin"
|
||||
[cdkConnectedOverlayOpen]="isOpen() && !isEditMode()"
|
||||
[cdkConnectedOverlayOffsetY]="8"
|
||||
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
|
||||
(backdropClick)="close()"
|
||||
(detach)="close()"
|
||||
>
|
||||
<ul
|
||||
#optionsList
|
||||
class="quantity-control__dropdown"
|
||||
role="listbox"
|
||||
[id]="listboxId"
|
||||
[attr.aria-label]="ariaLabel() || 'Quantity options'"
|
||||
>
|
||||
@for (option of generatedOptions(); track option.value) {
|
||||
@if (option.value === 'edit') {
|
||||
<shared-quantity-control-option
|
||||
[value]="option.value"
|
||||
[id]="listboxId + '-option-' + option.value"
|
||||
>
|
||||
<ng-icon name="isaActionEdit" size="1.5rem"></ng-icon>
|
||||
</shared-quantity-control-option>
|
||||
} @else {
|
||||
<shared-quantity-control-option
|
||||
[value]="option.value"
|
||||
[id]="listboxId + '-option-' + option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</shared-quantity-control-option>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,542 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { QuantityControlComponent } from './quantity-control.component';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||
import { TooltipDirective } from '@isa/ui/tooltip';
|
||||
|
||||
describe('QuantityControlComponent', () => {
|
||||
let component: QuantityControlComponent;
|
||||
let fixture: ComponentFixture<QuantityControlComponent>;
|
||||
let liveAnnouncerMock: { announce: ReturnType<typeof vi.fn> };
|
||||
let tooltipMock: { show: ReturnType<typeof vi.fn>; hide: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
liveAnnouncerMock = {
|
||||
announce: vi.fn(),
|
||||
};
|
||||
|
||||
tooltipMock = {
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [QuantityControlComponent],
|
||||
providers: [
|
||||
{ provide: LiveAnnouncer, useValue: liveAnnouncerMock },
|
||||
{ provide: TooltipDirective, useValue: tooltipMock },
|
||||
],
|
||||
})
|
||||
.overrideComponent(QuantityControlComponent, {
|
||||
remove: { imports: [TooltipDirective] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(QuantityControlComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('Component Creation', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have default value of 1', () => {
|
||||
expect(component.value()).toBe(1);
|
||||
});
|
||||
|
||||
it('should not be disabled by default', () => {
|
||||
expect(component.disabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have default min of 1', () => {
|
||||
expect(component.min()).toBe(1);
|
||||
});
|
||||
|
||||
it('should have default max of undefined', () => {
|
||||
expect(component.max()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have default presetLimit of 10', () => {
|
||||
expect(component.presetLimit()).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generated Options', () => {
|
||||
it('should generate options from min to presetLimit with Edit', () => {
|
||||
const options = component.generatedOptions();
|
||||
|
||||
expect(options).toHaveLength(11); // 1-10 + Edit
|
||||
expect(options[0]).toEqual({ value: 1, label: '1' });
|
||||
expect(options[9]).toEqual({ value: 10, label: '10' });
|
||||
expect(options[10]).toEqual({ value: 'edit', label: 'Edit' });
|
||||
});
|
||||
|
||||
it('should generate options starting from 0 when min is 0', () => {
|
||||
fixture.componentRef.setInput('min', 0);
|
||||
fixture.detectChanges();
|
||||
|
||||
const options = component.generatedOptions();
|
||||
|
||||
expect(options[0]).toEqual({ value: 0, label: '0' });
|
||||
expect(options[9]).toEqual({ value: 9, label: '9' });
|
||||
});
|
||||
|
||||
it('should not show Edit option when max is less than or equal to presetLimit', () => {
|
||||
fixture.componentRef.setInput('max', 5);
|
||||
fixture.componentRef.setInput('presetLimit', 10);
|
||||
fixture.detectChanges();
|
||||
|
||||
const options = component.generatedOptions();
|
||||
|
||||
expect(options).toHaveLength(5); // 1-5 (capped at max), no Edit
|
||||
expect(options[0]).toEqual({ value: 1, label: '1' });
|
||||
expect(options[4]).toEqual({ value: 5, label: '5' });
|
||||
expect(options[options.length - 1].value).not.toBe('edit');
|
||||
});
|
||||
|
||||
it('should show Edit option when max exceeds presetLimit', () => {
|
||||
fixture.componentRef.setInput('max', 50);
|
||||
fixture.componentRef.setInput('presetLimit', 10);
|
||||
fixture.detectChanges();
|
||||
|
||||
const options = component.generatedOptions();
|
||||
|
||||
expect(options).toHaveLength(11); // 1-10 + Edit
|
||||
expect(options[options.length - 1]).toEqual({ value: 'edit', label: 'Edit' });
|
||||
});
|
||||
|
||||
it('should generate correct range with custom min and presetLimit', () => {
|
||||
fixture.componentRef.setInput('min', 5);
|
||||
fixture.componentRef.setInput('presetLimit', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
const options = component.generatedOptions();
|
||||
|
||||
expect(options[0]).toEqual({ value: 5, label: '5' });
|
||||
expect(options[1]).toEqual({ value: 6, label: '6' });
|
||||
expect(options[2]).toEqual({ value: 7, label: '7' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Value Selection', () => {
|
||||
it('should update value when selectValue is called', () => {
|
||||
component.selectValue(5);
|
||||
|
||||
expect(component.value()).toBe(5);
|
||||
});
|
||||
|
||||
it('should call onChange callback when value changes', () => {
|
||||
const onChangeSpy = vi.fn();
|
||||
component.registerOnChange(onChangeSpy);
|
||||
|
||||
component.selectValue(3);
|
||||
|
||||
expect(onChangeSpy).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('should call onTouched callback when value changes', () => {
|
||||
const onTouchedSpy = vi.fn();
|
||||
component.registerOnTouched(onTouchedSpy);
|
||||
|
||||
component.selectValue(7);
|
||||
|
||||
expect(onTouchedSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close dropdown after selecting value', () => {
|
||||
component.isOpen.set(true);
|
||||
|
||||
component.selectValue(4);
|
||||
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should announce value change to screen readers', () => {
|
||||
component.selectValue(8);
|
||||
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Quantity changed to 8',
|
||||
'polite'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown State', () => {
|
||||
it('should open dropdown when open() is called', () => {
|
||||
component.open();
|
||||
|
||||
expect(component.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not open dropdown when disabled', () => {
|
||||
component.disabled.set(true);
|
||||
|
||||
component.open();
|
||||
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should close dropdown when close() is called', () => {
|
||||
component.isOpen.set(true);
|
||||
|
||||
component.close();
|
||||
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle dropdown state', () => {
|
||||
component.toggleDropdown();
|
||||
expect(component.isOpen()).toBe(true);
|
||||
|
||||
component.toggleDropdown();
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not toggle when disabled', () => {
|
||||
component.disabled.set(true);
|
||||
|
||||
component.toggleDropdown();
|
||||
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should enter edit mode when enterEditMode is called', () => {
|
||||
component.enterEditMode();
|
||||
|
||||
expect(component.isEditMode()).toBe(true);
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should announce edit mode activation', () => {
|
||||
component.enterEditMode();
|
||||
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Edit mode activated. Type a quantity and press Enter to confirm or Escape to cancel.',
|
||||
'polite'
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit edit mode with valid value', () => {
|
||||
component.enterEditMode();
|
||||
|
||||
component.exitEditMode('15');
|
||||
|
||||
expect(component.isEditMode()).toBe(false);
|
||||
expect(component.value()).toBe(15);
|
||||
});
|
||||
|
||||
it('should clamp to minimum value when input is too low', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
component.exitEditMode('0');
|
||||
|
||||
expect(component.isEditMode()).toBe(false);
|
||||
expect(component.value()).toBe(1); // Clamped to min
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Adjusted to minimum 1',
|
||||
'polite'
|
||||
);
|
||||
});
|
||||
|
||||
it('should clamp to maximum value when input is too high', () => {
|
||||
fixture.componentRef.setInput('max', 50);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
component.exitEditMode('100');
|
||||
|
||||
expect(component.isEditMode()).toBe(false);
|
||||
expect(component.value()).toBe(50); // Clamped to max
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Adjusted to maximum 50',
|
||||
'polite'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid (NaN) input in edit mode', () => {
|
||||
const originalValue = component.value();
|
||||
component.enterEditMode();
|
||||
|
||||
component.exitEditMode('abc');
|
||||
|
||||
expect(component.isEditMode()).toBe(false);
|
||||
expect(component.value()).toBe(originalValue); // Value unchanged
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Invalid input. Please enter a valid number.',
|
||||
'assertive'
|
||||
);
|
||||
});
|
||||
|
||||
it('should cancel edit mode without changing value', () => {
|
||||
const originalValue = component.value();
|
||||
component.enterEditMode();
|
||||
|
||||
component.cancelEditMode();
|
||||
|
||||
expect(component.isEditMode()).toBe(false);
|
||||
expect(component.value()).toBe(originalValue);
|
||||
});
|
||||
|
||||
it('should announce cancel', () => {
|
||||
component.enterEditMode();
|
||||
|
||||
component.cancelEditMode();
|
||||
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Edit mode cancelled',
|
||||
'polite'
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent double-exit when called multiple times', () => {
|
||||
component.enterEditMode();
|
||||
const onChangeSpy = vi.fn();
|
||||
component.registerOnChange(onChangeSpy);
|
||||
|
||||
component.exitEditMode('5');
|
||||
component.exitEditMode('10'); // Should be ignored
|
||||
|
||||
expect(component.value()).toBe(5);
|
||||
expect(onChangeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ControlValueAccessor', () => {
|
||||
it('should write value', () => {
|
||||
component.writeValue(7);
|
||||
|
||||
expect(component.value()).toBe(7);
|
||||
});
|
||||
|
||||
it('should reset to min when writing invalid value', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.writeValue(-5);
|
||||
|
||||
expect(component.value()).toBe(1);
|
||||
});
|
||||
|
||||
it('should reset to min when writing NaN', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.writeValue('invalid');
|
||||
|
||||
expect(component.value()).toBe(1);
|
||||
});
|
||||
|
||||
it('should set disabled state', () => {
|
||||
component.setDisabledState(true);
|
||||
|
||||
expect(component.disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with FormControl', () => {
|
||||
const formControl = new FormControl(5);
|
||||
|
||||
component.writeValue(formControl.value);
|
||||
component.registerOnChange((value) => formControl.setValue(value));
|
||||
|
||||
component.selectValue(10);
|
||||
|
||||
expect(component.value()).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should open dropdown on Enter key when closed', () => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
component.onKeydown(event);
|
||||
|
||||
expect(component.isOpen()).toBe(true);
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open dropdown on Space key when closed', () => {
|
||||
const event = new KeyboardEvent('keydown', { key: ' ' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
component.onKeydown(event);
|
||||
|
||||
expect(component.isOpen()).toBe(true);
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close dropdown on Escape key when open', () => {
|
||||
component.isOpen.set(true);
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
component.onKeydown(event);
|
||||
|
||||
expect(component.isOpen()).toBe(false);
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not handle keys when disabled', () => {
|
||||
component.disabled.set(true);
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
|
||||
component.onKeydown(event);
|
||||
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not handle keys when in edit mode', () => {
|
||||
component.isEditMode.set(true);
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
|
||||
component.onKeydown(event);
|
||||
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ARIA Attributes', () => {
|
||||
it('should have unique listbox ID', () => {
|
||||
expect(component.listboxId).toContain('quantity-control-listbox-');
|
||||
});
|
||||
|
||||
it('should compute activeDescendantId when dropdown is open', () => {
|
||||
component.isOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Without active item, should be null
|
||||
expect(component.activeDescendantId()).toBeNull();
|
||||
});
|
||||
|
||||
it('should compute activeDescendantId as null when dropdown is closed', () => {
|
||||
component.isOpen.set(false);
|
||||
|
||||
expect(component.activeDescendantId()).toBeNull();
|
||||
});
|
||||
|
||||
it('should compute chevron icon based on dropdown state', () => {
|
||||
expect(component.chevronIcon()).toBe('isaActionChevronDown');
|
||||
|
||||
component.isOpen.set(true);
|
||||
expect(component.chevronIcon()).toBe('isaActionChevronUp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Inputs', () => {
|
||||
it('should accept custom min value', () => {
|
||||
fixture.componentRef.setInput('min', 0);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.min()).toBe(0);
|
||||
});
|
||||
|
||||
it('should accept custom max value', () => {
|
||||
fixture.componentRef.setInput('max', 100);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.max()).toBe(100);
|
||||
});
|
||||
|
||||
it('should accept custom presetLimit', () => {
|
||||
fixture.componentRef.setInput('presetLimit', 20);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.presetLimit()).toBe(20);
|
||||
});
|
||||
|
||||
it('should accept custom ariaLabel', () => {
|
||||
fixture.componentRef.setInput('ariaLabel', 'Select quantity');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.ariaLabel()).toBe('Select quantity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip Validation', () => {
|
||||
beforeEach(() => {
|
||||
// Setup tooltip mock on component
|
||||
component.tooltipDir = vi.fn(() => tooltipMock) as any;
|
||||
});
|
||||
|
||||
it('should show tooltip when input is less than min', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
component.onInputChange('0');
|
||||
|
||||
expect(component.validationMessage()).toBe('Minimum ist 1');
|
||||
expect(tooltipMock.show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show tooltip when input is greater than max', () => {
|
||||
fixture.componentRef.setInput('max', 50);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
component.onInputChange('100');
|
||||
|
||||
expect(component.validationMessage()).toBe('Maximum ist 50');
|
||||
expect(tooltipMock.show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide tooltip when valid input is entered', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.componentRef.setInput('max', 50);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
// First enter invalid input
|
||||
component.onInputChange('0');
|
||||
expect(tooltipMock.show).toHaveBeenCalled();
|
||||
|
||||
// Then enter valid input
|
||||
tooltipMock.show.mockClear();
|
||||
component.onInputChange('25');
|
||||
|
||||
expect(component.validationMessage()).toBe('');
|
||||
expect(tooltipMock.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show tooltip for valid input on initial entry', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.componentRef.setInput('max', 50);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
component.onInputChange('25');
|
||||
|
||||
expect(component.validationMessage()).toBe('');
|
||||
expect(tooltipMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty input without showing tooltip', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
component.onInputChange('');
|
||||
|
||||
expect(component.validationMessage()).toBe('');
|
||||
expect(tooltipMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle NaN input without showing tooltip', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.detectChanges();
|
||||
component.enterEditMode();
|
||||
|
||||
component.onInputChange('abc');
|
||||
|
||||
expect(component.validationMessage()).toBe('');
|
||||
expect(tooltipMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
signal,
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { coerceNumberProperty } from '@angular/cdk/coercion';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
isaActionChevronUp,
|
||||
isaActionChevronDown,
|
||||
isaActionEdit,
|
||||
} from '@isa/icons';
|
||||
import { ActiveDescendantKeyManager, LiveAnnouncer } from '@angular/cdk/a11y';
|
||||
import { TooltipDirective } from '@isa/ui/tooltip';
|
||||
import { QuantityControlOptionComponent } from './quantity-control-option.component';
|
||||
import {
|
||||
QUANTITY_CONSTRAINTS,
|
||||
OnChangeFn,
|
||||
OnTouchedFn,
|
||||
SUPPORTED_KEYS,
|
||||
} from './quantity-control.types';
|
||||
|
||||
@Component({
|
||||
selector: 'shared-quantity-control',
|
||||
templateUrl: './quantity-control.component.html',
|
||||
styleUrls: ['./quantity-control.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
hostDirectives: [CdkOverlayOrigin],
|
||||
imports: [
|
||||
CdkConnectedOverlay,
|
||||
NgIcon,
|
||||
QuantityControlOptionComponent,
|
||||
TooltipDirective,
|
||||
],
|
||||
providers: [
|
||||
provideIcons({ isaActionChevronUp, isaActionChevronDown, isaActionEdit }),
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: QuantityControlComponent,
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
host: {
|
||||
'role': 'combobox',
|
||||
'[attr.aria-expanded]': 'isOpen()',
|
||||
'[attr.aria-haspopup]': '"listbox"',
|
||||
'[attr.aria-controls]': 'listboxId',
|
||||
'[attr.aria-activedescendant]': 'activeDescendantId()',
|
||||
'[attr.aria-disabled]': 'disabled()',
|
||||
'[attr.aria-label]': 'ariaLabel() || "Quantity selector"',
|
||||
'[class.disabled]': 'disabled()',
|
||||
'[class.open]': 'isOpen()',
|
||||
'[class.edit-mode]': 'isEditMode()',
|
||||
'(keydown)': 'onKeydown($event)',
|
||||
'[attr.tabindex]': 'disabled() ? -1 : 0',
|
||||
},
|
||||
})
|
||||
export class QuantityControlComponent implements ControlValueAccessor {
|
||||
readonly #logger = logger(() => ({
|
||||
component: 'QuantityControlComponent',
|
||||
}));
|
||||
|
||||
private readonly elementRef = inject(ElementRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly liveAnnouncer = inject(LiveAnnouncer);
|
||||
|
||||
// ControlValueAccessor callbacks - replaced by registerOnChange/registerOnTouched
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private _onChange: OnChangeFn = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private _onTouched: OnTouchedFn = () => {};
|
||||
private timeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
/**
|
||||
* Unique ID for the listbox element
|
||||
*/
|
||||
readonly listboxId = `quantity-control-listbox-${crypto.randomUUID()}`;
|
||||
|
||||
/**
|
||||
* Current quantity value
|
||||
* @default 1
|
||||
*/
|
||||
value = model<number>(1);
|
||||
|
||||
/**
|
||||
* Whether the control is disabled
|
||||
* @default false
|
||||
*/
|
||||
disabled = model(false);
|
||||
|
||||
/**
|
||||
* Minimum selectable value (starting point for dropdown options).
|
||||
* @default 1
|
||||
*/
|
||||
min = input<number, unknown>(1, {
|
||||
transform: coerceNumberProperty,
|
||||
});
|
||||
|
||||
/**
|
||||
* Maximum selectable value (e.g., stock level, available quantity).
|
||||
* If undefined, there is no upper limit and "Edit" is always shown.
|
||||
* @default undefined
|
||||
*/
|
||||
max = input<number | undefined, unknown>(undefined, {
|
||||
transform: (value: unknown) => {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return coerceNumberProperty(value);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Number of preset options to show in the dropdown before requiring "Edit".
|
||||
* Options shown: min, min+1, min+2, ... up to (min + presetLimit - 1).
|
||||
* If max <= presetLimit, no "Edit" option is shown.
|
||||
* @default 10
|
||||
*/
|
||||
presetLimit = input<number, unknown>(10, {
|
||||
transform: coerceNumberProperty,
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom ARIA label for the combobox
|
||||
*/
|
||||
ariaLabel = input<string>();
|
||||
|
||||
/**
|
||||
* Dropdown open state
|
||||
*/
|
||||
isOpen = signal(false);
|
||||
|
||||
/**
|
||||
* Edit mode state (showing input overlay)
|
||||
*/
|
||||
isEditMode = signal(false);
|
||||
|
||||
/**
|
||||
* Current input value being typed in edit mode (for real-time validation)
|
||||
*/
|
||||
currentInputValue = signal<string>('');
|
||||
|
||||
/**
|
||||
* Flag to prevent double-exit from edit mode (Enter + blur)
|
||||
*/
|
||||
private isExitingEditMode = false;
|
||||
|
||||
/**
|
||||
* View children - auto-generated options in template
|
||||
*/
|
||||
options = viewChildren(QuantityControlOptionComponent);
|
||||
|
||||
/**
|
||||
* Reference to the scrollable options list container
|
||||
*/
|
||||
optionsList = viewChild<ElementRef<HTMLUListElement>>('optionsList');
|
||||
|
||||
/**
|
||||
* Reference to the edit mode input
|
||||
*/
|
||||
editInput = viewChild<ElementRef<HTMLInputElement>>('editInput');
|
||||
|
||||
/**
|
||||
* Reference to the tooltip directive for programmatic control
|
||||
*/
|
||||
tooltipDir = viewChild(TooltipDirective);
|
||||
|
||||
/**
|
||||
* Stored button width to maintain consistent sizing in edit mode
|
||||
*/
|
||||
buttonWidth = signal<number | null>(null);
|
||||
|
||||
/**
|
||||
* CDK Overlay origin for positioning
|
||||
*/
|
||||
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
|
||||
|
||||
/**
|
||||
* Keyboard manager for dropdown navigation
|
||||
*/
|
||||
private keyManager?: ActiveDescendantKeyManager<QuantityControlOptionComponent>;
|
||||
|
||||
/**
|
||||
* Chevron icon based on dropdown state
|
||||
*/
|
||||
chevronIcon = computed(() =>
|
||||
this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown',
|
||||
);
|
||||
|
||||
/**
|
||||
* Active descendant ID for ARIA (current keyboard-focused option)
|
||||
*/
|
||||
activeDescendantId = computed(() => {
|
||||
if (!this.isOpen() || !this.keyManager?.activeItem) {
|
||||
return null;
|
||||
}
|
||||
const activeItem = this.keyManager.activeItem;
|
||||
return `${this.listboxId}-option-${activeItem.value()}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Validation message for the current input value
|
||||
* Returns empty string if valid, error message if invalid
|
||||
*/
|
||||
validationMessage = computed(() => {
|
||||
const inputValue = this.currentInputValue();
|
||||
if (!inputValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const num = parseInt(inputValue, QUANTITY_CONSTRAINTS.PARSE_RADIX);
|
||||
if (isNaN(num)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const minVal = this.min();
|
||||
const maxVal = this.max();
|
||||
|
||||
if (num < minVal) {
|
||||
return `Minimum ist ${minVal}`;
|
||||
}
|
||||
|
||||
if (maxVal !== undefined && num > maxVal) {
|
||||
return `Maximum ist ${maxVal}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-generated options: min to (min + presetLimit - 1) + optional "Edit"
|
||||
* Edit option is shown when max > presetLimit or max is undefined
|
||||
* Options are capped at max if defined
|
||||
*/
|
||||
generatedOptions = computed(() => {
|
||||
const minVal = this.min();
|
||||
const maxVal = this.max();
|
||||
const limit = this.presetLimit();
|
||||
const opts: Array<{ value: number | 'edit'; label: string }> = [];
|
||||
|
||||
// Calculate the effective upper bound for preset options
|
||||
const presetMax = minVal + limit - 1;
|
||||
const effectiveMax =
|
||||
maxVal !== undefined ? Math.min(presetMax, maxVal) : presetMax;
|
||||
|
||||
// Generate preset options from min to effectiveMax
|
||||
for (let i = minVal; i <= effectiveMax; i++) {
|
||||
opts.push({ value: i, label: `${i}` });
|
||||
}
|
||||
|
||||
// Add "Edit" option if:
|
||||
// - max is undefined (unlimited) OR
|
||||
// - max exceeds presetLimit (need Edit for higher values)
|
||||
const shouldShowEdit = maxVal === undefined || maxVal > presetMax;
|
||||
if (shouldShowEdit) {
|
||||
opts.push({ value: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
return opts;
|
||||
});
|
||||
|
||||
/**
|
||||
* Overlay minimum width (match button width)
|
||||
*/
|
||||
get overlayMinWidth() {
|
||||
return this.elementRef.nativeElement.offsetWidth;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Setup cleanup on component destruction
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
// Reinitialize keyManager when options change
|
||||
effect(() => {
|
||||
const options = this.options();
|
||||
if (options.length > 0) {
|
||||
this.initializeKeyManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources on component destruction
|
||||
*/
|
||||
private cleanup(): void {
|
||||
this.keyManager?.destroy();
|
||||
this.clearAllTimeouts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all scheduled timeouts
|
||||
*/
|
||||
private clearAllTimeouts(): void {
|
||||
this.timeouts.forEach((timeout) => clearTimeout(timeout));
|
||||
this.timeouts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a timeout and track it for cleanup
|
||||
*/
|
||||
private scheduleTimeout(callback: () => void, delay = 0): void {
|
||||
const timeout = setTimeout(() => {
|
||||
this.timeouts.delete(timeout);
|
||||
callback();
|
||||
}, delay);
|
||||
this.timeouts.add(timeout);
|
||||
}
|
||||
|
||||
writeValue(obj: unknown): void {
|
||||
const value = coerceNumberProperty(obj);
|
||||
const minVal = this.min();
|
||||
if (isNaN(value) || value < minVal) {
|
||||
this.#logger.warn(
|
||||
`Invalid value provided to QuantityControl: ${String(obj)}. Resetting to ${minVal}.`,
|
||||
);
|
||||
this.value.set(minVal);
|
||||
} else {
|
||||
this.value.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
registerOnChange(fn: OnChangeFn): void {
|
||||
this._onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: OnTouchedFn): void {
|
||||
this._onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the keyboard manager for dropdown navigation
|
||||
*/
|
||||
private initializeKeyManager(): void {
|
||||
this.keyManager?.destroy();
|
||||
|
||||
this.keyManager =
|
||||
new ActiveDescendantKeyManager<QuantityControlOptionComponent>(
|
||||
this.options(),
|
||||
).withWrap();
|
||||
|
||||
// Subscribe to changes in the active item to scroll it into view
|
||||
this.keyManager.change
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => {
|
||||
this.scrollActiveItemIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the active keyboard-focused item into view
|
||||
*/
|
||||
private scrollActiveItemIntoView(): void {
|
||||
const activeItem = this.keyManager?.activeItem;
|
||||
if (activeItem) {
|
||||
const activeElement = activeItem.elementRef.nativeElement;
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dropdown open/closed
|
||||
*/
|
||||
toggleDropdown() {
|
||||
if (this.disabled()) {
|
||||
return;
|
||||
}
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown
|
||||
*/
|
||||
open(): void {
|
||||
if (this.disabled()) {
|
||||
return;
|
||||
}
|
||||
this.isOpen.set(true);
|
||||
this._onTouched();
|
||||
|
||||
// Try to find and highlight the currently selected value
|
||||
this.scheduleTimeout(() => {
|
||||
const selectedOption = this.options().find(
|
||||
(opt: QuantityControlOptionComponent) => opt.value() === this.value(),
|
||||
);
|
||||
|
||||
if (selectedOption) {
|
||||
this.keyManager?.setActiveItem(selectedOption);
|
||||
} else {
|
||||
// If current value not in dropdown, highlight first item
|
||||
this.keyManager?.setFirstItemActive();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dropdown
|
||||
*/
|
||||
close(): void {
|
||||
this.isOpen.set(false);
|
||||
// Return focus to the component
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a numeric value from dropdown
|
||||
*/
|
||||
selectValue(n: number): void {
|
||||
this.value.set(n);
|
||||
this._onChange(n);
|
||||
this._onTouched();
|
||||
this.close();
|
||||
|
||||
// Announce change to screen readers
|
||||
this.liveAnnouncer.announce(`Quantity changed to ${n}`, 'polite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter edit mode - replace button with input
|
||||
*/
|
||||
enterEditMode(): void {
|
||||
// Capture current button width before switching modes
|
||||
const currentWidth = this.elementRef.nativeElement.offsetWidth;
|
||||
this.buttonWidth.set(currentWidth);
|
||||
|
||||
this.isOpen.set(false); // Close dropdown
|
||||
this.isEditMode.set(true); // Switch to input mode
|
||||
this.isExitingEditMode = false; // Reset flag
|
||||
this.currentInputValue.set(this.value().toString()); // Initialize with current value
|
||||
|
||||
// Announce edit mode to screen readers
|
||||
this.liveAnnouncer.announce(
|
||||
'Edit mode activated. Type a quantity and press Enter to confirm or Escape to cancel.',
|
||||
'polite',
|
||||
);
|
||||
|
||||
// Auto-focus input after it renders and select all content for easy replacement
|
||||
this.scheduleTimeout(() => {
|
||||
const input = this.editInput()?.nativeElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select(); // Select all text for easy replacement
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit edit mode - clamp value to valid range and update
|
||||
*/
|
||||
exitEditMode(inputValue: string): void {
|
||||
// Prevent double-exit (e.g., Enter key triggering both keydown.enter and blur)
|
||||
if (this.isExitingEditMode) {
|
||||
return;
|
||||
}
|
||||
this.isExitingEditMode = true;
|
||||
|
||||
const num = parseInt(inputValue, QUANTITY_CONSTRAINTS.PARSE_RADIX);
|
||||
const minVal = this.min();
|
||||
const maxVal = this.max();
|
||||
|
||||
// Reject only if truly invalid (NaN)
|
||||
if (isNaN(num)) {
|
||||
this.liveAnnouncer.announce(
|
||||
`Invalid input. Please enter a valid number.`,
|
||||
'assertive',
|
||||
);
|
||||
this.isEditMode.set(false);
|
||||
this.elementRef.nativeElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp value to valid range
|
||||
let clampedValue = Math.max(num, minVal); // Ensure >= min
|
||||
if (maxVal !== undefined) {
|
||||
clampedValue = Math.min(clampedValue, maxVal); // Ensure <= max (if defined)
|
||||
}
|
||||
|
||||
// Determine announcement message
|
||||
let announcement: string;
|
||||
if (num < minVal) {
|
||||
announcement = `Adjusted to minimum ${clampedValue}`;
|
||||
} else if (maxVal !== undefined && num > maxVal) {
|
||||
announcement = `Adjusted to maximum ${clampedValue}`;
|
||||
} else {
|
||||
announcement = `Quantity set to ${clampedValue}`;
|
||||
}
|
||||
|
||||
// Update value
|
||||
this.value.set(clampedValue);
|
||||
this._onChange(clampedValue);
|
||||
this._onTouched();
|
||||
this.liveAnnouncer.announce(announcement, 'polite');
|
||||
this.isEditMode.set(false);
|
||||
// Return focus to the component
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel edit mode without saving
|
||||
*/
|
||||
cancelEditMode(): void {
|
||||
// Prevent double-exit (e.g., Escape key triggering both keydown.escape and blur)
|
||||
if (this.isExitingEditMode) {
|
||||
return;
|
||||
}
|
||||
this.isExitingEditMode = true;
|
||||
|
||||
this.isEditMode.set(false);
|
||||
this.liveAnnouncer.announce('Edit mode cancelled', 'polite');
|
||||
// Return focus to the component
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input value changes in edit mode
|
||||
* Validates input and shows/hides tooltip based on validity
|
||||
*/
|
||||
onInputChange(value: string): void {
|
||||
this.currentInputValue.set(value);
|
||||
|
||||
const message = this.validationMessage();
|
||||
const tooltip = this.tooltipDir();
|
||||
|
||||
if (message && tooltip) {
|
||||
// Show tooltip with validation message
|
||||
tooltip.show();
|
||||
} else if (tooltip) {
|
||||
// Hide tooltip if input is valid
|
||||
tooltip.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in dropdown
|
||||
*/
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (this.disabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isEditMode()) {
|
||||
return; // Let input handle its own keys
|
||||
}
|
||||
|
||||
if (!this.isOpen()) {
|
||||
if (
|
||||
event.key === SUPPORTED_KEYS.ENTER ||
|
||||
event.key === SUPPORTED_KEYS.SPACE
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Prevent space from scrolling page
|
||||
this.open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropdown is open - handle all navigation keys
|
||||
if (event.key === SUPPORTED_KEYS.ESCAPE) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === SUPPORTED_KEYS.ENTER ||
|
||||
event.key === SUPPORTED_KEYS.SPACE
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Prevent space from scrolling page
|
||||
const activeItem = this.keyManager?.activeItem;
|
||||
if (activeItem) {
|
||||
activeItem.select();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys - prevent default and let key manager handle
|
||||
if (
|
||||
event.key === SUPPORTED_KEYS.ARROW_DOWN ||
|
||||
event.key === SUPPORTED_KEYS.ARROW_UP ||
|
||||
event.key === SUPPORTED_KEYS.HOME ||
|
||||
event.key === SUPPORTED_KEYS.END
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.keyManager?.onKeydown(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Type definitions and constants for QuantityControlComponent
|
||||
*/
|
||||
|
||||
/**
|
||||
* Quantity constraints and default values
|
||||
*/
|
||||
export const QUANTITY_CONSTRAINTS = {
|
||||
MIN_VALUE: 1,
|
||||
MAX_VALUE: Number.MAX_SAFE_INTEGER,
|
||||
DEFAULT_VALUE: 1,
|
||||
DEFAULT_MAX_SELECTABLE: 10,
|
||||
PARSE_RADIX: 10,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Callback function type for form control value changes
|
||||
*/
|
||||
export type OnChangeFn = (value: number) => void;
|
||||
|
||||
/**
|
||||
* Callback function type for form control touched event
|
||||
*/
|
||||
export type OnTouchedFn = () => void;
|
||||
|
||||
/**
|
||||
* Supported keyboard keys for component navigation
|
||||
*/
|
||||
export const SUPPORTED_KEYS = {
|
||||
ENTER: 'Enter',
|
||||
SPACE: ' ',
|
||||
ESCAPE: 'Escape',
|
||||
ARROW_DOWN: 'ArrowDown',
|
||||
ARROW_UP: 'ArrowUp',
|
||||
HOME: 'Home',
|
||||
END: 'End',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Union type of all supported keyboard keys
|
||||
*/
|
||||
export type SupportedKey = (typeof SUPPORTED_KEYS)[keyof typeof SUPPORTED_KEYS];
|
||||
13
libs/shared/quantity-control/src/test-setup.ts
Normal file
13
libs/shared/quantity-control/src/test-setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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(),
|
||||
);
|
||||
30
libs/shared/quantity-control/tsconfig.json
Normal file
30
libs/shared/quantity-control/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/shared/quantity-control/tsconfig.lib.json
Normal file
27
libs/shared/quantity-control/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
29
libs/shared/quantity-control/tsconfig.spec.json
Normal file
29
libs/shared/quantity-control/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
33
libs/shared/quantity-control/vite.config.mts
Normal file
33
libs/shared/quantity-control/vite.config.mts
Normal file
@@ -0,0 +1,33 @@
|
||||
/// <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
|
||||
// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
|
||||
defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/shared/quantity-control',
|
||||
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',
|
||||
['junit', { outputFile: '../../../testresults/junit-shared-quantity-control.xml' }],
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/shared/quantity-control',
|
||||
provider: 'v8' as const,
|
||||
reporter: ['text', 'cobertura'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user