Merged PR 1967: Reward Shopping Cart Implementation

This commit is contained in:
Lorenz Hilpert
2025-10-14 16:02:18 +00:00
committed by Nino Righi
parent d761704dc4
commit f15848d5c0
158 changed files with 46339 additions and 39059 deletions

View 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

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: 'shared',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'shared',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View 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"
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './lib/quantity-control.component';
export * from './lib/quantity-control-option.component';
export * from './lib/quantity-control.types';

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
<ng-content></ng-content>

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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();
});
});
});

View File

@@ -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;
}
}
}

View File

@@ -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];

View 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(),
);

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,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'],
},
},
}));