feat: implement variant selection in cart and product components for enhanced user experience

This commit is contained in:
2026-08-10 12:08:41 -03:00
parent dd828d9a4e
commit d6bab24e1d
17 changed files with 478 additions and 234 deletions

View File

@@ -0,0 +1,17 @@
@if (selectors().length > 0) {
<div class="variant-selector" [class.variant-selector--compact]="compact()">
@for (selector of selectors(); track selector.key) {
<select
class="form-select variant-selector__select"
[attr.aria-label]="selector.label"
[disabled]="disabled()"
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onValueChange(selector.key, $event)"
>
@for (option of selector.options; track option.key) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
}
</div>
}

View File

@@ -0,0 +1,40 @@
:host {
display: block;
min-width: 0;
}
.variant-selector {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.variant-selector__select {
width: auto;
min-width: 120px;
height: 38px;
color: #666666;
border-color: #cccccc;
font-size: 14px;
cursor: pointer;
&:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
}
}
.variant-selector--compact {
justify-content: flex-end;
gap: 4px;
.variant-selector__select {
min-width: 0;
max-width: 150px;
height: 28px;
padding: 0.2rem 1.75rem 0.2rem 0.45rem;
font-size: 11px;
}
}

View File

@@ -0,0 +1,41 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { VariantSelectorComponent } from './variant-selector.component';
describe('VariantSelectorComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => TestBed.resetTestingModule());
it('updates following selections to the first compatible variant', async () => {
await TestBed.configureTestingModule({
imports: [VariantSelectorComponent],
}).compileComponents();
const fixture = TestBed.createComponent(VariantSelectorComponent);
fixture.componentRef.setInput('variants', [
{ value: 1, values: { alojamiento: 'Carpa', servicio: 'Almuerzo' } },
{ value: 2, values: { alojamiento: 'Carpa', servicio: 'Cena' } },
{ value: 3, values: { alojamiento: 'Hotel', servicio: 'Cena' } },
]);
fixture.componentRef.setInput('selectedVariant', 1);
fixture.detectChanges();
(fixture.componentInstance as any).onValueChange('alojamiento', 'Hotel');
fixture.detectChanges();
expect(fixture.componentInstance.selectedVariant()).toBe(3);
expect((fixture.componentInstance as any).selectedValues()).toEqual({
alojamiento: 'Hotel',
servicio: 'Cena',
});
});
});

View File

@@ -0,0 +1,161 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
model,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
export type VariantAttributeValue = string | string[];
export interface VariantSelectorVariant {
value: unknown;
values: Record<string, VariantAttributeValue>;
}
interface VariantSelectorOption {
key: string;
label: string;
value: VariantAttributeValue;
}
interface VariantSelectorGroup {
key: string;
label: string;
options: VariantSelectorOption[];
}
@Component({
selector: 'app-variant-selector',
standalone: true,
imports: [FormsModule],
templateUrl: './variant-selector.component.html',
styleUrl: './variant-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VariantSelectorComponent {
readonly variants = input<VariantSelectorVariant[]>([]);
readonly selectedVariant = model<unknown>(null);
readonly disabled = input(false);
readonly compact = input(false);
protected readonly selectedValues = signal<Record<string, VariantAttributeValue>>({});
protected readonly attributeKeys = computed(() =>
Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))),
);
protected readonly selectors = computed<VariantSelectorGroup[]>(() => {
const variants = this.variants();
const keys = this.attributeKeys();
const selectedValues = this.selectedValues();
return keys.map((key, index) => {
const previousKeys = keys.slice(0, index);
const compatibleVariants = variants.filter((variant) =>
previousKeys.every((previousKey) =>
this.sameValue(variant.values[previousKey], selectedValues[previousKey]),
),
);
return {
key,
label: this.formatVariantLabel(key),
options: this.optionsFor(compatibleVariants, key),
};
});
});
constructor() {
effect(() => {
const variants = this.variants();
const selectedVariant = this.selectedVariant();
untracked(() => {
if (variants.length === 0) {
this.selectedValues.set({});
if (selectedVariant !== null) this.selectedVariant.set(null);
return;
}
const selected =
variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0];
this.selectedValues.set({ ...selected.values });
if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value);
});
});
}
protected onValueChange(key: string, value: VariantAttributeValue): void {
const variants = this.variants();
const keys = this.attributeKeys();
const changedIndex = keys.indexOf(key);
const values = { ...this.selectedValues(), [key]: value };
for (let index = changedIndex + 1; index < keys.length; index++) {
const currentKey = keys[index];
const previousKeys = keys.slice(0, index);
const compatibleVariants = variants.filter((variant) =>
previousKeys.every((previousKey) =>
this.sameValue(variant.values[previousKey], values[previousKey]),
),
);
const options = this.optionsFor(compatibleVariants, currentKey);
if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) {
const firstOption = options[0];
if (firstOption) values[currentKey] = firstOption.value;
else delete values[currentKey];
}
}
const matchingVariant = variants.find((variant) =>
keys.every((attributeKey) =>
this.sameValue(variant.values[attributeKey], values[attributeKey]),
),
);
this.selectedValues.set(values);
this.selectedVariant.set(matchingVariant?.value ?? null);
}
private optionsFor(variants: VariantSelectorVariant[], key: string): VariantSelectorOption[] {
const options = new Map<string, VariantSelectorOption>();
for (const variant of variants) {
const value = variant.values[key];
if (value === undefined || value === '') continue;
const optionKey = this.valueKey(value);
if (!options.has(optionKey)) {
options.set(optionKey, {
key: optionKey,
label: Array.isArray(value) ? value.join(', ') : value,
value,
});
}
}
return Array.from(options.values());
}
private sameValue(
left: VariantAttributeValue | undefined,
right: VariantAttributeValue | undefined,
): boolean {
return (
left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right)
);
}
private valueKey(value: VariantAttributeValue): string {
return JSON.stringify(value);
}
private formatVariantLabel(key: string): string {
const label = key.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}
}