mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 2057: feat(checkout): add branch selection to reward catalog
feat(checkout): add branch selection to reward catalog - Add new select-branch-dropdown library with BranchDropdownComponent and SelectedBranchDropdownComponent for branch selection - Extend DropdownButtonComponent with filter and option subcomponents - Integrate branch selection into reward catalog page - Add BranchesResource for fetching available branches - Update CheckoutMetadataService with branch selection persistence - Add comprehensive tests for dropdown components Related work items: #5464
This commit is contained in:
committed by
Nino Righi
parent
4589146e31
commit
7950994d66
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@isa/crm/data-access';
|
} from '@isa/crm/data-access';
|
||||||
import { TabService } from '@isa/core/tabs';
|
import { TabService } from '@isa/core/tabs';
|
||||||
import { BranchDTO } from '@generated/swagger/checkout-api';
|
import { BranchDTO } from '@generated/swagger/checkout-api';
|
||||||
|
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for opening and managing the Purchase Options Modal.
|
* Service for opening and managing the Purchase Options Modal.
|
||||||
@@ -39,6 +40,7 @@ export class PurchaseOptionsModalService {
|
|||||||
#uiModal = inject(UiModalService);
|
#uiModal = inject(UiModalService);
|
||||||
#tabService = inject(TabService);
|
#tabService = inject(TabService);
|
||||||
#crmTabMetadataService = inject(CrmTabMetadataService);
|
#crmTabMetadataService = inject(CrmTabMetadataService);
|
||||||
|
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||||
#customerFacade = inject(CustomerFacade);
|
#customerFacade = inject(CustomerFacade);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +76,10 @@ export class PurchaseOptionsModalService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
context.selectedCustomer = await this.#getSelectedCustomer(data);
|
context.selectedCustomer = await this.#getSelectedCustomer(data);
|
||||||
context.selectedBranch = this.#getSelectedBranch(data.tabId);
|
context.selectedBranch = this.#getSelectedBranch(
|
||||||
|
data.tabId,
|
||||||
|
data.useRedemptionPoints,
|
||||||
|
);
|
||||||
return this.#uiModal.open<string, PurchaseOptionsModalContext>({
|
return this.#uiModal.open<string, PurchaseOptionsModalContext>({
|
||||||
content: PurchaseOptionsModalComponent,
|
content: PurchaseOptionsModalComponent,
|
||||||
data: context,
|
data: context,
|
||||||
@@ -95,7 +100,10 @@ export class PurchaseOptionsModalService {
|
|||||||
return this.#customerFacade.fetchCustomer({ customerId });
|
return this.#customerFacade.fetchCustomer({ customerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
#getSelectedBranch(tabId: number): BranchDTO | undefined {
|
#getSelectedBranch(
|
||||||
|
tabId: number,
|
||||||
|
useRedemptionPoints: boolean,
|
||||||
|
): BranchDTO | undefined {
|
||||||
const tab = untracked(() =>
|
const tab = untracked(() =>
|
||||||
this.#tabService.entities().find((t) => t.id === tabId),
|
this.#tabService.entities().find((t) => t.id === tabId),
|
||||||
);
|
);
|
||||||
@@ -104,6 +112,10 @@ export class PurchaseOptionsModalService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useRedemptionPoints) {
|
||||||
|
return this.#checkoutMetadataService.getSelectedBranch(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
const legacyProcessData = tab?.metadata?.process_data;
|
const legacyProcessData = tab?.metadata?.process_data;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,51 +1,106 @@
|
|||||||
import {
|
import {
|
||||||
DropdownAppearance,
|
DropdownAppearance,
|
||||||
DropdownButtonComponent,
|
DropdownButtonComponent,
|
||||||
DropdownOptionComponent,
|
DropdownFilterComponent,
|
||||||
} from '@isa/ui/input-controls';
|
DropdownOptionComponent,
|
||||||
import { type Meta, type StoryObj, argsToTemplate, moduleMetadata } from '@storybook/angular';
|
} from '@isa/ui/input-controls';
|
||||||
|
import {
|
||||||
type DropdownInputProps = {
|
type Meta,
|
||||||
value: string;
|
type StoryObj,
|
||||||
label: string;
|
argsToTemplate,
|
||||||
appearance: DropdownAppearance;
|
moduleMetadata,
|
||||||
};
|
} from '@storybook/angular';
|
||||||
|
|
||||||
const meta: Meta<DropdownInputProps> = {
|
type DropdownInputProps = {
|
||||||
title: 'ui/input-controls/Dropdown',
|
value: string;
|
||||||
decorators: [
|
label: string;
|
||||||
moduleMetadata({
|
appearance: DropdownAppearance;
|
||||||
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
};
|
||||||
}),
|
|
||||||
],
|
const meta: Meta<DropdownInputProps> = {
|
||||||
argTypes: {
|
title: 'ui/input-controls/Dropdown',
|
||||||
value: { control: 'text' },
|
decorators: [
|
||||||
label: { control: 'text' },
|
moduleMetadata({
|
||||||
appearance: {
|
imports: [
|
||||||
control: 'select',
|
DropdownButtonComponent,
|
||||||
options: Object.values(DropdownAppearance),
|
DropdownFilterComponent,
|
||||||
},
|
DropdownOptionComponent,
|
||||||
},
|
],
|
||||||
render: (args) => ({
|
}),
|
||||||
props: args,
|
],
|
||||||
template: `
|
argTypes: {
|
||||||
<ui-dropdown ${argsToTemplate(args)}>
|
value: { control: 'text' },
|
||||||
<ui-dropdown-option value="">Select an option</ui-dropdown-option>
|
label: { control: 'text' },
|
||||||
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
|
appearance: {
|
||||||
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
|
control: 'select',
|
||||||
<ui-dropdown-option value="3">Option 3</ui-dropdown-option>
|
options: Object.values(DropdownAppearance),
|
||||||
</ui-dropdown>
|
},
|
||||||
`,
|
},
|
||||||
}),
|
render: (args) => ({
|
||||||
};
|
props: args,
|
||||||
|
template: `
|
||||||
export default meta;
|
<ui-dropdown ${argsToTemplate(args)}>
|
||||||
|
<ui-dropdown-option value="">Select an option</ui-dropdown-option>
|
||||||
type Story = StoryObj<DropdownInputProps>;
|
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
|
||||||
export const Default: Story = {
|
<ui-dropdown-option value="3">Option 3</ui-dropdown-option>
|
||||||
args: {
|
</ui-dropdown>
|
||||||
value: undefined,
|
`,
|
||||||
label: 'Label',
|
}),
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<DropdownInputProps>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
value: undefined,
|
||||||
|
label: 'Label',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithFilter: Story = {
|
||||||
|
args: {
|
||||||
|
value: undefined,
|
||||||
|
label: 'Select a country',
|
||||||
|
},
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<ui-dropdown ${argsToTemplate(args)}>
|
||||||
|
<ui-dropdown-filter placeholder="Search countries..."></ui-dropdown-filter>
|
||||||
|
<ui-dropdown-option value="">Select a country</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="de">Germany</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="at">Austria</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="ch">Switzerland</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="fr">France</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="it">Italy</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="es">Spain</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="nl">Netherlands</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="be">Belgium</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="pl">Poland</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="cz">Czech Republic</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GreyAppearance: Story = {
|
||||||
|
args: {
|
||||||
|
value: undefined,
|
||||||
|
label: 'Filter',
|
||||||
|
appearance: DropdownAppearance.Grey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => ({
|
||||||
|
template: `
|
||||||
|
<ui-dropdown label="Disabled dropdown" [disabled]="true">
|
||||||
|
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Library Reference Guide
|
# Library Reference Guide
|
||||||
|
|
||||||
> **Last Updated:** 2025-11-25
|
> **Last Updated:** 2025-11-27
|
||||||
> **Angular Version:** 20.3.6
|
> **Angular Version:** 20.3.6
|
||||||
> **Nx Version:** 21.3.2
|
> **Nx Version:** 21.3.2
|
||||||
> **Total Libraries:** 72
|
> **Total Libraries:** 73
|
||||||
|
|
||||||
All 72 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
|
All 73 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
|
||||||
|
|
||||||
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
|
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ A comprehensive product catalogue search service for Angular applications, provi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Checkout Domain (6 libraries)
|
## Checkout Domain (7 libraries)
|
||||||
|
|
||||||
### `@isa/checkout/data-access`
|
### `@isa/checkout/data-access`
|
||||||
A comprehensive checkout and shopping cart management library for Angular applications supporting multiple order types, reward redemption, and complex multi-step checkout workflows across retail and e-commerce operations.
|
A comprehensive checkout and shopping cart management library for Angular applications supporting multiple order types, reward redemption, and complex multi-step checkout workflows across retail and e-commerce operations.
|
||||||
@@ -46,6 +46,11 @@ A comprehensive reward shopping cart feature for Angular applications supporting
|
|||||||
|
|
||||||
**Location:** `libs/checkout/feature/reward-shopping-cart/`
|
**Location:** `libs/checkout/feature/reward-shopping-cart/`
|
||||||
|
|
||||||
|
### `@isa/checkout/feature/select-branch-dropdown`
|
||||||
|
Branch selection dropdown components for the checkout domain, enabling users to select a branch for their checkout session.
|
||||||
|
|
||||||
|
**Location:** `libs/checkout/feature/select-branch-dropdown/`
|
||||||
|
|
||||||
### `@isa/checkout/shared/product-info`
|
### `@isa/checkout/shared/product-info`
|
||||||
A comprehensive collection of presentation components for displaying product information, destination details, and stock availability in checkout and rewards workflows.
|
A comprehensive collection of presentation components for displaying product information, destination details, and stock availability in checkout and rewards workflows.
|
||||||
|
|
||||||
@@ -432,4 +437,4 @@ This file should be updated when:
|
|||||||
- Library purposes significantly change
|
- Library purposes significantly change
|
||||||
- Angular or Nx versions are upgraded
|
- Angular or Nx versions are upgraded
|
||||||
|
|
||||||
**Automation:** This file is auto-generated using `npm run docs:generate`. Run this command after adding or modifying libraries to keep the documentation up-to-date.
|
**Automation:** This file is auto-generated using `npm run docs:generate`. Run this command after adding or modifying libraries to keep the documentation up-to-date.
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { CheckoutMetadataService } from '../services/checkout-metadata.service';
|
import { CheckoutMetadataService } from '../services/checkout-metadata.service';
|
||||||
|
import { Branch } from '../schemas/branch.schema';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BranchFacade {
|
export class BranchFacade {
|
||||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||||
|
|
||||||
getSelectedBranchId(tabId: number): number | undefined {
|
getSelectedBranch(tabId: number): Branch | undefined {
|
||||||
return this.#checkoutMetadataService.getSelectedBranchId(tabId);
|
return this.#checkoutMetadataService.getSelectedBranch(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedBranchId(tabId: number, branchId: number | undefined): void {
|
setSelectedBranch(tabId: number, branch: Branch | undefined): void {
|
||||||
this.#checkoutMetadataService.setSelectedBranchId(tabId, branchId);
|
this.#checkoutMetadataService.setSelectedBranch(tabId, branch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { inject, Injectable, resource, signal } from '@angular/core';
|
import { inject, Injectable, resource, signal } from '@angular/core';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
import { BranchService } from '../services';
|
import { BranchService } from '../services';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BranchResource {
|
export class BranchResource {
|
||||||
|
#logger = logger({ service: 'BranchResource' });
|
||||||
#branchService = inject(BranchService);
|
#branchService = inject(BranchService);
|
||||||
|
|
||||||
#params = signal<
|
#params = signal<
|
||||||
@@ -24,17 +26,20 @@ export class BranchResource {
|
|||||||
if ('branchId' in params && !params.branchId) {
|
if ('branchId' in params && !params.branchId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
this.#logger.debug('Loading branch', () => params);
|
||||||
const res = await this.#branchService.fetchBranches(abortSignal);
|
const res = await this.#branchService.fetchBranches(abortSignal);
|
||||||
return res.find((b) => {
|
const branch = res.find((b) => {
|
||||||
if ('branchId' in params) {
|
if ('branchId' in params) {
|
||||||
return b.id === params.branchId;
|
return b.id === params.branchId;
|
||||||
} else {
|
} else {
|
||||||
return b.branchNumber === params.branchNumber;
|
return b.branchNumber === params.branchNumber;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.#logger.debug('Branch loaded', () => ({
|
||||||
|
found: !!branch,
|
||||||
|
branchId: branch?.id,
|
||||||
|
}));
|
||||||
|
return branch;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AssignedBranchResource {}
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { inject, Injectable, resource } from '@angular/core';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
import { BranchService } from '../services';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BranchesResource {
|
||||||
|
#logger = logger({ service: 'BranchesResource' });
|
||||||
|
#branchService = inject(BranchService);
|
||||||
|
|
||||||
|
readonly resource = resource({
|
||||||
|
loader: async ({ abortSignal }) => {
|
||||||
|
this.#logger.debug('Loading all branches');
|
||||||
|
const branches = await this.#branchService.fetchBranches(abortSignal);
|
||||||
|
this.#logger.debug('Branches loaded', () => ({ count: branches.length }));
|
||||||
|
return branches
|
||||||
|
.filter(
|
||||||
|
(branch) =>
|
||||||
|
branch.isOnline &&
|
||||||
|
branch.isShippingEnabled &&
|
||||||
|
branch.isOrderingEnabled &&
|
||||||
|
branch.name !== undefined,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name!.localeCompare(b.name!));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './branch.resource';
|
export * from './branch.resource';
|
||||||
|
export * from './branches.resource';
|
||||||
export * from './shopping-cart.resource';
|
export * from './shopping-cart.resource';
|
||||||
|
|||||||
@@ -4,27 +4,26 @@ import {
|
|||||||
CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY,
|
CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY,
|
||||||
CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY,
|
CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY,
|
||||||
CHECKOUT_SHOPPING_CART_ID_METADATA_KEY,
|
CHECKOUT_SHOPPING_CART_ID_METADATA_KEY,
|
||||||
COMPLETED_SHOPPING_CARTS_METADATA_KEY,
|
|
||||||
SELECTED_BRANCH_METADATA_KEY,
|
SELECTED_BRANCH_METADATA_KEY,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { ShoppingCart } from '../models';
|
import { Branch, BranchSchema } from '../schemas/branch.schema';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CheckoutMetadataService {
|
export class CheckoutMetadataService {
|
||||||
#tabService = inject(TabService);
|
#tabService = inject(TabService);
|
||||||
|
|
||||||
setSelectedBranchId(tabId: number, branchId: number | undefined) {
|
setSelectedBranch(tabId: number, branch: Branch | undefined) {
|
||||||
this.#tabService.patchTabMetadata(tabId, {
|
this.#tabService.patchTabMetadata(tabId, {
|
||||||
[SELECTED_BRANCH_METADATA_KEY]: branchId,
|
[SELECTED_BRANCH_METADATA_KEY]: branch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectedBranchId(tabId: number): number | undefined {
|
getSelectedBranch(tabId: number): Branch | undefined {
|
||||||
return getMetadataHelper(
|
return getMetadataHelper(
|
||||||
tabId,
|
tabId,
|
||||||
SELECTED_BRANCH_METADATA_KEY,
|
SELECTED_BRANCH_METADATA_KEY,
|
||||||
z.number().optional(),
|
BranchSchema.optional(),
|
||||||
this.#tabService.entityMap(),
|
this.#tabService.entityMap(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
|
@let tId = tabId();
|
||||||
<reward-header></reward-header>
|
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
|
||||||
<filter-controls-panel
|
<reward-header></reward-header>
|
||||||
[switchFilters]="displayStockFilterSwitch()"
|
<filter-controls-panel
|
||||||
(triggerSearch)="search($event)"
|
[forceMobileLayout]="isCallCenter"
|
||||||
></filter-controls-panel>
|
[switchFilters]="displayStockFilterSwitch()"
|
||||||
<reward-list
|
(triggerSearch)="search($event)"
|
||||||
[searchTrigger]="searchTrigger()"
|
>
|
||||||
(searchTriggerChange)="searchTrigger.set($event)"
|
@if (isCallCenter && tId) {
|
||||||
></reward-list>
|
<checkout-selected-branch-dropdown [tabId]="tId" />
|
||||||
<reward-action></reward-action>
|
}
|
||||||
|
</filter-controls-panel>
|
||||||
|
<reward-list
|
||||||
|
[searchTrigger]="searchTrigger()"
|
||||||
|
(searchTriggerChange)="searchTrigger.set($event)"
|
||||||
|
></reward-list>
|
||||||
|
<reward-action></reward-action>
|
||||||
|
|||||||
@@ -14,14 +14,18 @@ import {
|
|||||||
SearchTrigger,
|
SearchTrigger,
|
||||||
FilterService,
|
FilterService,
|
||||||
FilterInput,
|
FilterInput,
|
||||||
|
SwitchMenuButtonComponent,
|
||||||
} from '@isa/shared/filter';
|
} from '@isa/shared/filter';
|
||||||
import { RewardHeaderComponent } from './reward-header/reward-header.component';
|
import { RewardHeaderComponent } from './reward-header/reward-header.component';
|
||||||
import { RewardListComponent } from './reward-list/reward-list.component';
|
import { RewardListComponent } from './reward-list/reward-list.component';
|
||||||
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
|
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
|
||||||
import { RewardActionComponent } from './reward-action/reward-action.component';
|
import { RewardActionComponent } from './reward-action/reward-action.component';
|
||||||
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
|
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
|
||||||
|
import { SelectedBranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
|
||||||
import { SelectedCustomerResource } from '@isa/crm/data-access';
|
import { SelectedCustomerResource } from '@isa/crm/data-access';
|
||||||
import { OpenTasksCarouselComponent } from './open-tasks-carousel/open-tasks-carousel.component';
|
import { OpenTasksCarouselComponent } from './open-tasks-carousel/open-tasks-carousel.component';
|
||||||
|
import { injectTabId } from '@isa/core/tabs';
|
||||||
|
import { Role, RoleService } from '@isa/core/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory function to retrieve query settings from the activated route data.
|
* Factory function to retrieve query settings from the activated route data.
|
||||||
@@ -50,6 +54,7 @@ function querySettingsFactory() {
|
|||||||
RewardHeaderComponent,
|
RewardHeaderComponent,
|
||||||
RewardListComponent,
|
RewardListComponent,
|
||||||
RewardActionComponent,
|
RewardActionComponent,
|
||||||
|
SelectedBranchDropdownComponent,
|
||||||
],
|
],
|
||||||
host: {
|
host: {
|
||||||
'[class]':
|
'[class]':
|
||||||
@@ -57,6 +62,10 @@ function querySettingsFactory() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class RewardCatalogComponent {
|
export class RewardCatalogComponent {
|
||||||
|
readonly tabId = injectTabId();
|
||||||
|
|
||||||
|
readonly isCallCenter = inject(RoleService).hasRole(Role.CallCenter);
|
||||||
|
|
||||||
restoreScrollPosition = injectRestoreScrollPosition();
|
restoreScrollPosition = injectRestoreScrollPosition();
|
||||||
|
|
||||||
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
|
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
|
||||||
@@ -64,6 +73,9 @@ export class RewardCatalogComponent {
|
|||||||
#filterService = inject(FilterService);
|
#filterService = inject(FilterService);
|
||||||
|
|
||||||
displayStockFilterSwitch = computed(() => {
|
displayStockFilterSwitch = computed(() => {
|
||||||
|
if (this.isCallCenter) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const stockInput = this.#filterService
|
const stockInput = this.#filterService
|
||||||
.inputs()
|
.inputs()
|
||||||
?.filter((input) => input.target === 'filter')
|
?.filter((input) => input.target === 'filter')
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
@let i = item();
|
@let i = item();
|
||||||
<ui-client-row data-what="reward-list-item" [attr.data-which]="i.id">
|
<ui-client-row data-what="reward-list-item" [attr.data-which]="i.id">
|
||||||
<ui-client-row-content
|
<ui-client-row-content
|
||||||
class="flex-grow"
|
class="flex-grow"
|
||||||
[class.row-start-1]="!desktopBreakpoint()"
|
[class.row-start-1]="!desktopBreakpoint()"
|
||||||
>
|
>
|
||||||
<checkout-product-info-redemption
|
<checkout-product-info-redemption
|
||||||
[item]="i"
|
[item]="i"
|
||||||
[orientation]="productInfoOrientation()"
|
[orientation]="productInfoOrientation()"
|
||||||
></checkout-product-info-redemption>
|
></checkout-product-info-redemption>
|
||||||
</ui-client-row-content>
|
</ui-client-row-content>
|
||||||
<ui-item-row-data
|
<ui-item-row-data
|
||||||
class="flex-grow"
|
class="flex-grow"
|
||||||
[class.stock-row-tablet]="!desktopBreakpoint()"
|
[class.stock-row-tablet]="!desktopBreakpoint()"
|
||||||
>
|
>
|
||||||
<checkout-stock-info [item]="i"></checkout-stock-info>
|
<checkout-stock-info
|
||||||
</ui-item-row-data>
|
[item]="i"
|
||||||
|
[branchId]="selectedBranchId()"
|
||||||
<ui-item-row-data
|
></checkout-stock-info>
|
||||||
class="justify-center"
|
</ui-item-row-data>
|
||||||
[class.select-row-tablet]="!desktopBreakpoint()"
|
|
||||||
>
|
<ui-item-row-data
|
||||||
<reward-list-item-select
|
class="justify-center"
|
||||||
class="self-end"
|
[class.select-row-tablet]="!desktopBreakpoint()"
|
||||||
[item]="i"
|
>
|
||||||
></reward-list-item-select>
|
<reward-list-item-select
|
||||||
</ui-item-row-data>
|
class="self-end"
|
||||||
</ui-client-row>
|
[item]="i"
|
||||||
|
></reward-list-item-select>
|
||||||
|
</ui-item-row-data>
|
||||||
|
</ui-client-row>
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
inject,
|
||||||
input,
|
input,
|
||||||
linkedSignal,
|
linkedSignal,
|
||||||
|
computed,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Item } from '@isa/catalogue/data-access';
|
import { Item } from '@isa/catalogue/data-access';
|
||||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||||
|
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||||
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
|
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
|
||||||
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
|
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
|
||||||
import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component';
|
import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component';
|
||||||
|
import { injectTabId } from '@isa/core/tabs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'reward-list-item',
|
selector: 'reward-list-item',
|
||||||
@@ -25,6 +29,18 @@ import { RewardListItemSelectComponent } from './reward-list-item-select/reward-
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class RewardListItemComponent {
|
export class RewardListItemComponent {
|
||||||
|
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||||
|
|
||||||
|
tabId = injectTabId();
|
||||||
|
|
||||||
|
selectedBranchId = computed(() => {
|
||||||
|
const tabid = this.tabId();
|
||||||
|
if (!tabid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.#checkoutMetadataService.getSelectedBranch(tabid)?.id;
|
||||||
|
});
|
||||||
|
|
||||||
item = input.required<Item>();
|
item = input.required<Item>();
|
||||||
|
|
||||||
desktopBreakpoint = breakpoint([
|
desktopBreakpoint = breakpoint([
|
||||||
|
|||||||
460
libs/checkout/feature/select-branch-dropdown/README.md
Normal file
460
libs/checkout/feature/select-branch-dropdown/README.md
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
# @isa/checkout/feature/select-branch-dropdown
|
||||||
|
|
||||||
|
Branch selection dropdown components for the checkout domain, enabling users to select a branch for their checkout session.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Select Branch Dropdown feature library provides two UI components for branch selection in the checkout flow:
|
||||||
|
|
||||||
|
- **SelectedBranchDropdownComponent** - High-level component that integrates with `CheckoutMetadataService` for automatic persistence
|
||||||
|
- **BranchDropdownComponent** - Low-level component with `ControlValueAccessor` support for custom form integration
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Component API Reference](#component-api-reference)
|
||||||
|
- [Usage Examples](#usage-examples)
|
||||||
|
- [State Management](#state-management)
|
||||||
|
- [Dependencies](#dependencies)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Best Practices](#best-practices)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Branch Selection** - Dropdown interface for selecting a branch
|
||||||
|
- **Metadata Integration** - Persists selected branch in checkout metadata via `CheckoutMetadataService.setSelectedBranch()`
|
||||||
|
- **Standalone Component** - Modern Angular standalone architecture
|
||||||
|
- **Responsive Design** - Works across tablet and desktop layouts
|
||||||
|
- **E2E Testing Support** - Includes data-what and data-which attributes
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Import the Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SelectedBranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-checkout',
|
||||||
|
imports: [SelectedBranchDropdownComponent],
|
||||||
|
template: `
|
||||||
|
<checkout-selected-branch-dropdown [tabId]="tabId()" />
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class CheckoutComponent {
|
||||||
|
tabId = injectTabId();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Using the Low-Level Component with Forms
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-branch-form',
|
||||||
|
imports: [BranchDropdownComponent, ReactiveFormsModule],
|
||||||
|
template: `
|
||||||
|
<checkout-branch-dropdown formControlName="branch" />
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class BranchFormComponent {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component API Reference
|
||||||
|
|
||||||
|
### SelectedBranchDropdownComponent
|
||||||
|
|
||||||
|
High-level branch selection component with automatic `CheckoutMetadataService` integration.
|
||||||
|
|
||||||
|
**Selector:** `checkout-selected-branch-dropdown`
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
| Input | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `tabId` | `number` | Yes | - | The tab ID for metadata storage |
|
||||||
|
| `appearance` | `DropdownAppearance` | No | `AccentOutline` | Visual style of the dropdown |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```html
|
||||||
|
<checkout-selected-branch-dropdown
|
||||||
|
[tabId]="tabId()"
|
||||||
|
[appearance]="DropdownAppearance.AccentOutline"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BranchDropdownComponent
|
||||||
|
|
||||||
|
Low-level branch dropdown with `ControlValueAccessor` support for reactive forms.
|
||||||
|
|
||||||
|
**Selector:** `checkout-branch-dropdown`
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
| Input | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `appearance` | `DropdownAppearance` | No | `AccentOutline` | Visual style of the dropdown |
|
||||||
|
|
||||||
|
**Outputs:**
|
||||||
|
| Output | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `selected` | `Branch \| null` | Two-way bindable selected branch (model) |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```html
|
||||||
|
<!-- With ngModel -->
|
||||||
|
<checkout-branch-dropdown [(selected)]="selectedBranch" />
|
||||||
|
|
||||||
|
<!-- With reactive forms -->
|
||||||
|
<checkout-branch-dropdown formControlName="branch" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Usage in Checkout Flow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { SelectedBranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
|
||||||
|
import { injectTabId } from '@isa/core/tabs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-checkout-header',
|
||||||
|
imports: [SelectedBranchDropdownComponent],
|
||||||
|
template: `
|
||||||
|
<div class="checkout-header">
|
||||||
|
<h1>Checkout</h1>
|
||||||
|
<checkout-selected-branch-dropdown [tabId]="tabId()" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class CheckoutHeaderComponent {
|
||||||
|
tabId = injectTabId();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using BranchDropdownComponent with Reactive Forms
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { BranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-branch-form-example',
|
||||||
|
imports: [BranchDropdownComponent, ReactiveFormsModule],
|
||||||
|
template: `
|
||||||
|
<form [formGroup]="form">
|
||||||
|
<checkout-branch-dropdown formControlName="branch" />
|
||||||
|
</form>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class BranchFormExampleComponent {
|
||||||
|
#fb = inject(FormBuilder);
|
||||||
|
form = this.#fb.group({
|
||||||
|
branch: [null]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading Selected Branch from Metadata
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, inject, computed } from '@angular/core';
|
||||||
|
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||||
|
import { injectTabId } from '@isa/core/tabs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-branch-reader-example',
|
||||||
|
template: `
|
||||||
|
@if (selectedBranch(); as branch) {
|
||||||
|
<p>Selected Branch: {{ branch.name }}</p>
|
||||||
|
} @else {
|
||||||
|
<p>No branch selected</p>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class BranchReaderExampleComponent {
|
||||||
|
#checkoutMetadata = inject(CheckoutMetadataService);
|
||||||
|
tabId = injectTabId();
|
||||||
|
|
||||||
|
selectedBranch = computed(() => {
|
||||||
|
return this.#checkoutMetadata.getSelectedBranch(this.tabId()!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### CheckoutMetadataService Integration
|
||||||
|
|
||||||
|
The component integrates with `CheckoutMetadataService` from `@isa/checkout/data-access`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Set selected branch (stores the full Branch object)
|
||||||
|
this.#checkoutMetadata.setSelectedBranch(tabId, branch);
|
||||||
|
|
||||||
|
// Get selected branch (returns Branch | undefined)
|
||||||
|
const branch = this.#checkoutMetadata.getSelectedBranch(tabId);
|
||||||
|
|
||||||
|
// Clear selected branch
|
||||||
|
this.#checkoutMetadata.setSelectedBranch(tabId, undefined);
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Persistence:**
|
||||||
|
- Branch selection persists in tab metadata
|
||||||
|
- Available across all checkout components in the same tab
|
||||||
|
- Cleared when tab is closed or session ends
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required Libraries
|
||||||
|
|
||||||
|
#### Angular Core
|
||||||
|
- `@angular/core` - Angular framework
|
||||||
|
- `@angular/forms` - Form controls and ControlValueAccessor
|
||||||
|
|
||||||
|
#### ISA Feature Libraries
|
||||||
|
- `@isa/checkout/data-access` - CheckoutMetadataService, BranchesResource, Branch type
|
||||||
|
- `@isa/core/logging` - Logger utility
|
||||||
|
|
||||||
|
#### ISA UI Libraries
|
||||||
|
- `@isa/ui/input-controls` - DropdownButtonComponent, DropdownFilterComponent, DropdownOptionComponent
|
||||||
|
|
||||||
|
### Path Alias
|
||||||
|
|
||||||
|
Import from: `@isa/checkout/feature/select-branch-dropdown`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Import components
|
||||||
|
import {
|
||||||
|
SelectedBranchDropdownComponent,
|
||||||
|
BranchDropdownComponent
|
||||||
|
} from '@isa/checkout/feature/select-branch-dropdown';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The library uses **Vitest** with **Angular Testing Utilities** for testing.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests for this library
|
||||||
|
npx nx test checkout-feature-select-branch-dropdown --skip-nx-cache
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
npx nx test checkout-feature-select-branch-dropdown --coverage.enabled=true --skip-nx-cache
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
npx nx test checkout-feature-select-branch-dropdown --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Configuration
|
||||||
|
|
||||||
|
- **Framework:** Vitest
|
||||||
|
- **Test Runner:** @nx/vite:test
|
||||||
|
- **Coverage:** v8 provider with Cobertura reports
|
||||||
|
- **JUnit XML:** `testresults/junit-checkout-feature-select-branch-dropdown.xml`
|
||||||
|
- **Coverage XML:** `coverage/libs/checkout/feature/select-branch-dropdown/cobertura-coverage.xml`
|
||||||
|
|
||||||
|
### Testing Recommendations
|
||||||
|
|
||||||
|
#### Component Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { SelectedBranchDropdownComponent } from './selected-branch-dropdown.component';
|
||||||
|
import { CheckoutMetadataService, BranchesResource } from '@isa/checkout/data-access';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
|
||||||
|
describe('SelectedBranchDropdownComponent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SelectedBranchDropdownComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: CheckoutMetadataService,
|
||||||
|
useValue: {
|
||||||
|
setSelectedBranch: vi.fn(),
|
||||||
|
getSelectedBranch: vi.fn(() => null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: BranchesResource,
|
||||||
|
useValue: {
|
||||||
|
resource: {
|
||||||
|
hasValue: () => true,
|
||||||
|
value: () => []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
const fixture = TestBed.createComponent(SelectedBranchDropdownComponent);
|
||||||
|
fixture.componentRef.setInput('tabId', 1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Attribute Requirements
|
||||||
|
|
||||||
|
All interactive elements should include `data-what` and `data-which` attributes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<select
|
||||||
|
data-what="branch-dropdown"
|
||||||
|
data-which="branch-selection">
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Option -->
|
||||||
|
<option
|
||||||
|
data-what="branch-option"
|
||||||
|
[attr.data-which]="branch.id">
|
||||||
|
</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Use Tab Context
|
||||||
|
|
||||||
|
Ensure tab context is available when using the component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
const tabId = this.#tabService.activatedTabId();
|
||||||
|
if (tabId) {
|
||||||
|
this.#checkoutMetadata.setSelectedBranch(tabId, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad (missing tab context validation)
|
||||||
|
this.#checkoutMetadata.setSelectedBranch(null, branch);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Clear Branch on Checkout Completion
|
||||||
|
|
||||||
|
Always clear branch selection when checkout is complete:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
completeCheckout() {
|
||||||
|
// ... checkout logic
|
||||||
|
this.#checkoutMetadata.setSelectedBranch(tabId, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad (leaves stale branch)
|
||||||
|
completeCheckout() {
|
||||||
|
// ... checkout logic
|
||||||
|
// Forgot to clear branch!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Computed Signals for Derived State
|
||||||
|
|
||||||
|
Leverage Angular signals for reactive values:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
selectedBranch = computed(() => {
|
||||||
|
return this.#checkoutMetadata.getSelectedBranch(this.tabId()!);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bad (manual tracking)
|
||||||
|
selectedBranch: Branch | undefined;
|
||||||
|
ngOnInit() {
|
||||||
|
effect(() => {
|
||||||
|
this.selectedBranch = this.#checkoutMetadata.getSelectedBranch(this.tabId()!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Follow OnPush Change Detection
|
||||||
|
|
||||||
|
Always use OnPush change detection for performance:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
@Component({
|
||||||
|
selector: 'checkout-select-branch-dropdown',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bad (default change detection)
|
||||||
|
@Component({
|
||||||
|
selector: 'checkout-select-branch-dropdown',
|
||||||
|
// Missing changeDetection
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Add E2E Attributes
|
||||||
|
|
||||||
|
Always include data-what and data-which attributes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
<select
|
||||||
|
data-what="branch-dropdown"
|
||||||
|
data-which="branch-selection"
|
||||||
|
[(ngModel)]="selectedBranch">
|
||||||
|
</select>
|
||||||
|
|
||||||
|
// Bad (no E2E attributes)
|
||||||
|
<select [(ngModel)]="selectedBranch">
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SelectedBranchDropdownComponent (high-level, with metadata integration)
|
||||||
|
├── Uses BranchDropdownComponent internally
|
||||||
|
├── CheckoutMetadataService integration
|
||||||
|
├── Tab context via tabId input
|
||||||
|
└── Automatic branch persistence
|
||||||
|
|
||||||
|
BranchDropdownComponent (low-level, form-compatible)
|
||||||
|
├── ControlValueAccessor implementation
|
||||||
|
├── BranchesResource for loading options
|
||||||
|
├── DropdownButtonComponent from @isa/ui/input-controls
|
||||||
|
└── Filter support via DropdownFilterComponent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SelectedBranchDropdownComponent:
|
||||||
|
1. Component receives tabId input
|
||||||
|
↓
|
||||||
|
2. Reads current branch from CheckoutMetadataService.getSelectedBranch(tabId)
|
||||||
|
↓
|
||||||
|
3. User selects branch from dropdown
|
||||||
|
↓
|
||||||
|
4. setSelectedBranch(tabId, branch) called
|
||||||
|
↓
|
||||||
|
5. Other checkout components react to metadata change
|
||||||
|
|
||||||
|
BranchDropdownComponent:
|
||||||
|
1. BranchesResource loads available branches
|
||||||
|
↓
|
||||||
|
2. User selects branch from dropdown
|
||||||
|
↓
|
||||||
|
3. selected model emits new value
|
||||||
|
↓
|
||||||
|
4. ControlValueAccessor notifies form (if used with forms)
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Internal ISA Frontend library - not for external distribution.
|
||||||
@@ -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: 'checkout',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'checkout',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
20
libs/checkout/feature/select-branch-dropdown/project.json
Normal file
20
libs/checkout/feature/select-branch-dropdown/project.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "checkout-feature-select-branch-dropdown",
|
||||||
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/checkout/feature/select-branch-dropdown/src",
|
||||||
|
"prefix": "checkout",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": ["scope:checkout", "type:feature"],
|
||||||
|
"targets": {
|
||||||
|
"test": {
|
||||||
|
"executor": "@nx/vite:test",
|
||||||
|
"outputs": ["{options.reportsDirectory}"],
|
||||||
|
"options": {
|
||||||
|
"reportsDirectory": "../../../../coverage/libs/checkout/feature/select-branch-dropdown"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './lib/branch-dropdown.component';
|
||||||
|
export * from './lib/selected-branch-dropdown.component';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<ui-dropdown
|
||||||
|
class="max-w-64"
|
||||||
|
[value]="selected()"
|
||||||
|
[appearance]="appearance()"
|
||||||
|
label="Filiale auswählen"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
[equals]="equals"
|
||||||
|
(valueChange)="onSelectionChange($event)"
|
||||||
|
data-what="branch-dropdown"
|
||||||
|
data-which="select-branch"
|
||||||
|
aria-label="Select branch"
|
||||||
|
>
|
||||||
|
<ui-dropdown-filter placeholder="Filiale suchen..."></ui-dropdown-filter>
|
||||||
|
@for (branch of options(); track branch.id) {
|
||||||
|
<ui-dropdown-option
|
||||||
|
[value]="branch"
|
||||||
|
[attr.data-what]="'branch-option'"
|
||||||
|
[attr.data-which]="branch.id"
|
||||||
|
>
|
||||||
|
{{ branch.key }} - {{ branch.name }}
|
||||||
|
</ui-dropdown-option>
|
||||||
|
} @empty {
|
||||||
|
<ui-dropdown-option [value]="null" [disabled]="true">
|
||||||
|
Keine Filialen verfügbar
|
||||||
|
</ui-dropdown-option>
|
||||||
|
}
|
||||||
|
</ui-dropdown>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
forwardRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
linkedSignal,
|
||||||
|
model,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
DropdownButtonComponent,
|
||||||
|
DropdownAppearance,
|
||||||
|
DropdownFilterComponent,
|
||||||
|
DropdownOptionComponent,
|
||||||
|
} from '@isa/ui/input-controls';
|
||||||
|
import { Branch, BranchesResource } from '@isa/checkout/data-access';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'checkout-branch-dropdown',
|
||||||
|
templateUrl: 'branch-dropdown.component.html',
|
||||||
|
styleUrls: ['branch-dropdown.component.css'],
|
||||||
|
imports: [
|
||||||
|
DropdownButtonComponent,
|
||||||
|
DropdownFilterComponent,
|
||||||
|
DropdownOptionComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => BranchDropdownComponent),
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BranchDropdownComponent implements ControlValueAccessor {
|
||||||
|
#logger = logger({ component: 'BranchDropdownComponent' });
|
||||||
|
#branchesResource = inject(BranchesResource).resource;
|
||||||
|
|
||||||
|
readonly DropdownAppearance = DropdownAppearance;
|
||||||
|
|
||||||
|
readonly appearance = input<DropdownAppearance>(
|
||||||
|
DropdownAppearance.AccentOutline,
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly selected = model<Branch | null>(null);
|
||||||
|
readonly disabled = signal(false);
|
||||||
|
|
||||||
|
readonly options = linkedSignal<Branch[]>(() =>
|
||||||
|
this.#branchesResource.hasValue() ? this.#branchesResource.value() : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly equals = (a: Branch | null, b: Branch | null) => a?.id === b?.id;
|
||||||
|
|
||||||
|
#onChange?: (value: Branch | null) => void;
|
||||||
|
#onTouched?: () => void;
|
||||||
|
|
||||||
|
writeValue(value: Branch | null): void {
|
||||||
|
this.#logger.debug('writeValue', () => ({ branchId: value?.id }));
|
||||||
|
this.selected.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (value: Branch | null) => void): void {
|
||||||
|
this.#onChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.#onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.#logger.debug('setDisabledState', () => ({ isDisabled }));
|
||||||
|
this.disabled.set(isDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectionChange(branch: Branch | undefined): void {
|
||||||
|
const value = branch ?? null;
|
||||||
|
this.#logger.debug('Selection changed', () => ({ branchId: value?.id }));
|
||||||
|
this.selected.set(value);
|
||||||
|
this.#onChange?.(value);
|
||||||
|
this.#onTouched?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Component, inject, input, linkedSignal } from '@angular/core';
|
||||||
|
import {
|
||||||
|
Branch,
|
||||||
|
BranchesResource,
|
||||||
|
CheckoutMetadataService,
|
||||||
|
} from '@isa/checkout/data-access';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
import { DropdownAppearance } from '@isa/ui/input-controls';
|
||||||
|
import { BranchDropdownComponent } from './branch-dropdown.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'checkout-selected-branch-dropdown',
|
||||||
|
template: `
|
||||||
|
<checkout-branch-dropdown
|
||||||
|
[selected]="selectedBranch()"
|
||||||
|
(selectedChange)="selectBranch($event)"
|
||||||
|
[appearance]="appearance()"
|
||||||
|
data-what="selected-branch-dropdown"
|
||||||
|
data-which="checkout-metadata"
|
||||||
|
/>
|
||||||
|
`,
|
||||||
|
imports: [BranchDropdownComponent],
|
||||||
|
providers: [BranchesResource],
|
||||||
|
})
|
||||||
|
export class SelectedBranchDropdownComponent {
|
||||||
|
#logger = logger({ component: 'SelectedBranchDropdownComponent' });
|
||||||
|
#metadataService = inject(CheckoutMetadataService);
|
||||||
|
|
||||||
|
readonly tabId = input.required<number>();
|
||||||
|
readonly appearance = input<DropdownAppearance>(
|
||||||
|
DropdownAppearance.AccentOutline,
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly selectedBranch = linkedSignal<Branch | null>(() => {
|
||||||
|
return this.#metadataService.getSelectedBranch(this.tabId()) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
selectBranch(branch: Branch | null) {
|
||||||
|
this.#logger.debug('selectBranch', () => ({ branchId: branch?.id }));
|
||||||
|
|
||||||
|
if (branch?.id === this.selectedBranch()?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#metadataService.setSelectedBranch(this.tabId(), branch ?? undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/checkout/feature/select-branch-dropdown/tsconfig.json
Normal file
30
libs/checkout/feature/select-branch-dropdown/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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
35
libs/checkout/feature/select-branch-dropdown/vite.config.mts
Normal file
35
libs/checkout/feature/select-branch-dropdown/vite.config.mts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/// <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
|
||||||
|
defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir:
|
||||||
|
'../../../../node_modules/.vite/libs/checkout/feature/select-branch-dropdown',
|
||||||
|
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-checkout-feature-select-branch-dropdown.xml' }],
|
||||||
|
],
|
||||||
|
coverage: {
|
||||||
|
reportsDirectory:
|
||||||
|
'../../../../coverage/libs/checkout/feature/select-branch-dropdown',
|
||||||
|
provider: 'v8' as const,
|
||||||
|
reporter: ['text', 'cobertura'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { StockInfoResource } from '@isa/remission/data-access';
|
import { StockInfoResource } from '@isa/remission/data-access';
|
||||||
|
import { Branch } from '@isa/checkout/data-access';
|
||||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||||
import { isaFiliale } from '@isa/icons';
|
import { isaFiliale } from '@isa/icons';
|
||||||
import { Item } from '@isa/catalogue/data-access';
|
import { Item } from '@isa/catalogue/data-access';
|
||||||
@@ -31,10 +32,17 @@ export class StockInfoComponent {
|
|||||||
|
|
||||||
item = input.required<StockInfoItem>();
|
item = input.required<StockInfoItem>();
|
||||||
|
|
||||||
|
branchId = input<number | null>(null);
|
||||||
|
|
||||||
itemId = computed(() => this.item().id);
|
itemId = computed(() => this.item().id);
|
||||||
|
|
||||||
readonly stockInfoResource = this.#stockInfoResource.resource(
|
readonly stockInfoResource = this.#stockInfoResource.resource(
|
||||||
computed(() => ({ itemId: this.itemId() })),
|
computed(() => {
|
||||||
|
return {
|
||||||
|
itemId: this.itemId(),
|
||||||
|
branchId: this.branchId() ?? undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
inStock = computed(() => this.stockInfoResource.value()?.inStock ?? 0);
|
inStock = computed(() => this.stockInfoResource.value()?.inStock ?? 0);
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { RemissionStockService } from '../services';
|
|||||||
import { FetchStockInStock } from '../schemas';
|
import { FetchStockInStock } from '../schemas';
|
||||||
import { StockInfo } from '../models';
|
import { StockInfo } from '../models';
|
||||||
|
|
||||||
|
export type StockInfoResourceParams = { itemId: number } & (
|
||||||
|
| { stockId?: number }
|
||||||
|
| { branchId: number }
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smart batching resource for stock information.
|
* Smart batching resource for stock information.
|
||||||
* Collects item params from multiple components, waits for a batching window,
|
* Collects item params from multiple components, waits for a batching window,
|
||||||
@@ -19,11 +24,12 @@ import { StockInfo } from '../models';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class StockInfoResource extends BatchingResource<
|
export class StockInfoResource extends BatchingResource<
|
||||||
{ itemId: number; stockId?: number },
|
StockInfoResourceParams,
|
||||||
FetchStockInStock,
|
FetchStockInStock,
|
||||||
StockInfo
|
StockInfo
|
||||||
> {
|
> {
|
||||||
#stockService = inject(RemissionStockService);
|
#stockService = inject(RemissionStockService);
|
||||||
|
#currentBatchBranchId?: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(250); // batchWindowMs
|
super(250); // batchWindowMs
|
||||||
@@ -43,27 +49,51 @@ export class StockInfoResource extends BatchingResource<
|
|||||||
* Build API request params from list of item params.
|
* Build API request params from list of item params.
|
||||||
*/
|
*/
|
||||||
protected buildParams(
|
protected buildParams(
|
||||||
paramsList: { itemId: number; stockId?: number }[],
|
paramsList: { itemId: number; stockId?: number; branchId?: number }[],
|
||||||
): FetchStockInStock {
|
): FetchStockInStock {
|
||||||
|
const first = paramsList[0];
|
||||||
|
// Track branchId for result matching (StockInfo doesn't contain branchId)
|
||||||
|
this.#currentBatchBranchId = first?.branchId;
|
||||||
|
|
||||||
|
if (first?.branchId !== undefined) {
|
||||||
|
return {
|
||||||
|
itemIds: paramsList.map((p) => p.itemId),
|
||||||
|
branchId: first.branchId,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
itemIds: paramsList.map((p) => p.itemId),
|
itemIds: paramsList.map((p) => p.itemId),
|
||||||
stockId: paramsList[0]?.stockId,
|
stockId: first?.stockId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract params from result for cache matching.
|
* Extract params from result for cache matching.
|
||||||
|
* Uses tracked branchId since StockInfo doesn't contain it.
|
||||||
*/
|
*/
|
||||||
protected getKeyFromResult(
|
protected getKeyFromResult(
|
||||||
stock: StockInfo,
|
stock: StockInfo,
|
||||||
): { itemId: number; stockId?: number } | undefined {
|
): { itemId: number; stockId?: number; branchId?: number } | undefined {
|
||||||
return stock.itemId !== undefined ? { itemId: stock.itemId } : undefined;
|
if (stock.itemId === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
itemId: stock.itemId,
|
||||||
|
branchId: this.#currentBatchBranchId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate cache key from params.
|
* Generate cache key from params.
|
||||||
*/
|
*/
|
||||||
protected getCacheKey(params: { itemId: number; stockId?: number }): string {
|
protected getCacheKey(params: {
|
||||||
|
itemId: number;
|
||||||
|
stockId?: number;
|
||||||
|
branchId?: number;
|
||||||
|
}): string {
|
||||||
|
if (params.branchId !== undefined) {
|
||||||
|
return `branch-${params.branchId}-${params.itemId}`;
|
||||||
|
}
|
||||||
return `${params.stockId ?? 'default'}-${params.itemId}`;
|
return `${params.stockId ?? 'default'}-${params.itemId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +106,8 @@ export class StockInfoResource extends BatchingResource<
|
|||||||
): { itemId: number; stockId?: number }[] {
|
): { itemId: number; stockId?: number }[] {
|
||||||
return params.itemIds.map((itemId) => ({
|
return params.itemIds.map((itemId) => ({
|
||||||
itemId,
|
itemId,
|
||||||
stockId: params.stockId,
|
stockId: 'stockId' in params ? params.stockId : undefined,
|
||||||
|
branchId: 'branchId' in params ? params.branchId : undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const FetchStockInStockSchema = z.object({
|
export const FetchStockInStockWithStockIdSchema = z.object({
|
||||||
stockId: z.number().describe('Stock identifier').optional(),
|
stockId: z.number().describe('Stock identifier').optional(),
|
||||||
itemIds: z.array(z.number()).describe('Item ids'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FetchStockInStockWithBranchIdSchema = z.object({
|
||||||
|
branchId: z.number().describe('Branch identifier'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FetchStockInStockSchema = z
|
||||||
|
.object({
|
||||||
|
itemIds: z.array(z.number()).describe('Item ids'),
|
||||||
|
})
|
||||||
|
.and(
|
||||||
|
z.union([
|
||||||
|
FetchStockInStockWithBranchIdSchema,
|
||||||
|
FetchStockInStockWithStockIdSchema,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
export type FetchStockInStock = z.infer<typeof FetchStockInStockSchema>;
|
export type FetchStockInStock = z.infer<typeof FetchStockInStockSchema>;
|
||||||
|
|||||||
@@ -157,8 +157,17 @@ export class RemissionStockService {
|
|||||||
|
|
||||||
let assignedStockId: number;
|
let assignedStockId: number;
|
||||||
|
|
||||||
if (parsed.stockId) {
|
if ('stockId' in parsed && parsed.stockId) {
|
||||||
assignedStockId = parsed.stockId;
|
assignedStockId = parsed.stockId;
|
||||||
|
} else if ('branchId' in parsed) {
|
||||||
|
const stock = await this.fetchStock(parsed.branchId, abortSignal);
|
||||||
|
if (!stock) {
|
||||||
|
this.#logger.warn('No stock found for branch', () => ({
|
||||||
|
branchId: parsed.branchId,
|
||||||
|
}));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
assignedStockId = stock.id;
|
||||||
} else {
|
} else {
|
||||||
assignedStockId = await this.fetchAssignedStock(abortSignal).then(
|
assignedStockId = await this.fetchAssignedStock(abortSignal).then(
|
||||||
(s) => s.id,
|
(s) => s.id,
|
||||||
@@ -166,7 +175,7 @@ export class RemissionStockService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#logger.info('Fetching stock info from API', () => ({
|
this.#logger.info('Fetching stock info from API', () => ({
|
||||||
stockId: parsed.stockId,
|
stockId: assignedStockId,
|
||||||
itemCount: parsed.itemIds.length,
|
itemCount: parsed.itemIds.length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,57 @@
|
|||||||
<div
|
<div
|
||||||
class="w-full flex flex-row justify-between items-start"
|
class="w-full flex flex-row justify-between items-start"
|
||||||
[class.empty-filter-input]="!hasFilter() && !hasInput()"
|
[class.empty-filter-input]="!hasFilter() && !hasInput()"
|
||||||
>
|
>
|
||||||
@if (hasInput()) {
|
@if (hasInput()) {
|
||||||
<filter-search-bar-input
|
<filter-search-bar-input
|
||||||
class="flex flex-row gap-4 h-12"
|
class="flex flex-row gap-4 h-12"
|
||||||
[appearance]="'results'"
|
[appearance]="'results'"
|
||||||
[inputKey]="inputKey()"
|
[inputKey]="inputKey()"
|
||||||
(triggerSearch)="triggerSearch.emit($event)"
|
(triggerSearch)="triggerSearch.emit($event)"
|
||||||
data-what="search-input"
|
data-what="search-input"
|
||||||
></filter-search-bar-input>
|
></filter-search-bar-input>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex flex-row gap-4 items-center">
|
<div class="flex flex-row gap-4 items-center">
|
||||||
@for (switchFilter of switchFilters(); track switchFilter.filter.key) {
|
<ng-content></ng-content>
|
||||||
<filter-switch-menu-button
|
|
||||||
[filterInput]="switchFilter.filter"
|
@for (switchFilter of switchFilters(); track switchFilter.filter.key) {
|
||||||
[icon]="switchFilter.icon"
|
<filter-switch-menu-button
|
||||||
(toggled)="triggerSearch.emit('filter')"
|
[filterInput]="switchFilter.filter"
|
||||||
></filter-switch-menu-button>
|
[icon]="switchFilter.icon"
|
||||||
}
|
(toggled)="triggerSearch.emit('filter')"
|
||||||
|
></filter-switch-menu-button>
|
||||||
@if (hasFilter()) {
|
}
|
||||||
<filter-filter-menu-button
|
|
||||||
(applied)="triggerSearch.emit('filter')"
|
@if (hasFilter()) {
|
||||||
[rollbackOnClose]="true"
|
<filter-filter-menu-button
|
||||||
></filter-filter-menu-button>
|
(applied)="triggerSearch.emit('filter')"
|
||||||
}
|
[rollbackOnClose]="true"
|
||||||
|
></filter-filter-menu-button>
|
||||||
@if (mobileBreakpoint()) {
|
}
|
||||||
<ui-icon-button
|
|
||||||
type="button"
|
@if (mobileLayout()) {
|
||||||
(click)="showOrderByToolbarMobile.set(!showOrderByToolbarMobile())"
|
<ui-icon-button
|
||||||
[class.active]="showOrderByToolbarMobile()"
|
type="button"
|
||||||
data-what="sort-button-mobile"
|
(click)="showOrderByToolbarMobile.set(!showOrderByToolbarMobile())"
|
||||||
name="isaActionSort"
|
[class.active]="showOrderByToolbarMobile()"
|
||||||
></ui-icon-button>
|
data-what="sort-button-mobile"
|
||||||
} @else {
|
name="isaActionSort"
|
||||||
<filter-order-by-toolbar
|
></ui-icon-button>
|
||||||
[class.empty-filter-input-width]="!hasFilter() && !hasInput()"
|
} @else {
|
||||||
(toggled)="triggerSearch.emit('order-by')"
|
<filter-order-by-toolbar
|
||||||
data-what="sort-toolbar"
|
[class.empty-filter-input-width]="!hasFilter() && !hasInput()"
|
||||||
></filter-order-by-toolbar>
|
(toggled)="triggerSearch.emit('order-by')"
|
||||||
}
|
data-what="sort-toolbar"
|
||||||
</div>
|
></filter-order-by-toolbar>
|
||||||
</div>
|
}
|
||||||
|
</div>
|
||||||
@if (mobileBreakpoint() && showOrderByToolbarMobile()) {
|
</div>
|
||||||
<filter-order-by-toolbar
|
|
||||||
class="w-full"
|
@if (mobileLayout() && showOrderByToolbarMobile()) {
|
||||||
(toggled)="triggerSearch.emit('order-by')"
|
<filter-order-by-toolbar
|
||||||
data-what="sort-toolbar-mobile"
|
class="w-full"
|
||||||
></filter-order-by-toolbar>
|
(toggled)="triggerSearch.emit('order-by')"
|
||||||
}
|
data-what="sort-toolbar-mobile"
|
||||||
|
></filter-order-by-toolbar>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,135 +1,141 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
linkedSignal,
|
linkedSignal,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
ViewEncapsulation,
|
ViewEncapsulation,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FilterMenuButtonComponent } from '../menus/filter-menu';
|
import { FilterMenuButtonComponent } from '../menus/filter-menu';
|
||||||
import { provideIcons } from '@ng-icons/core';
|
import { provideIcons } from '@ng-icons/core';
|
||||||
import { isaActionFilter, isaActionSort } from '@isa/icons';
|
import { isaActionFilter, isaActionSort } from '@isa/icons';
|
||||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||||
import { OrderByToolbarComponent } from '../order-by';
|
import { OrderByToolbarComponent } from '../order-by';
|
||||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||||
import { InputType, SearchTrigger } from '../types';
|
import { InputType, SearchTrigger } from '../types';
|
||||||
import { FilterService, TextFilterInput, FilterInput } from '../core';
|
import { FilterService, TextFilterInput, FilterInput } from '../core';
|
||||||
import { SearchBarInputComponent } from '../inputs';
|
import { SearchBarInputComponent } from '../inputs';
|
||||||
import { SwitchMenuButtonComponent } from '../menus/switch-menu';
|
import { SwitchMenuButtonComponent } from '../menus/switch-menu';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter controls panel component that provides a unified interface for search and filtering operations.
|
* Filter controls panel component that provides a unified interface for search and filtering operations.
|
||||||
*
|
*
|
||||||
* This component combines search input, filter menu, and sorting controls into a responsive panel.
|
* This component combines search input, filter menu, and sorting controls into a responsive panel.
|
||||||
* It adapts its layout based on screen size, showing/hiding controls appropriately for mobile and desktop views.
|
* It adapts its layout based on screen size, showing/hiding controls appropriately for mobile and desktop views.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```html
|
* ```html
|
||||||
* <filter-controls-panel
|
* <filter-controls-panel
|
||||||
* (triggerSearch)="handleSearch($event)">
|
* (triggerSearch)="handleSearch($event)">
|
||||||
* </filter-controls-panel>
|
* </filter-controls-panel>
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Responsive design that adapts to mobile/desktop layouts
|
* - Responsive design that adapts to mobile/desktop layouts
|
||||||
* - Integrated search bar with scanner support
|
* - Integrated search bar with scanner support
|
||||||
* - Filter menu with rollback functionality
|
* - Filter menu with rollback functionality
|
||||||
* - Sortable order-by controls
|
* - Sortable order-by controls
|
||||||
* - Emits typed search trigger events
|
* - Emits typed search trigger events
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'filter-controls-panel',
|
selector: 'filter-controls-panel',
|
||||||
templateUrl: './controls-panel.component.html',
|
templateUrl: './controls-panel.component.html',
|
||||||
styleUrls: ['./controls-panel.component.scss'],
|
styleUrls: ['./controls-panel.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
encapsulation: ViewEncapsulation.None,
|
encapsulation: ViewEncapsulation.None,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
SearchBarInputComponent,
|
SearchBarInputComponent,
|
||||||
FilterMenuButtonComponent,
|
FilterMenuButtonComponent,
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
OrderByToolbarComponent,
|
OrderByToolbarComponent,
|
||||||
SwitchMenuButtonComponent,
|
SwitchMenuButtonComponent,
|
||||||
],
|
],
|
||||||
host: {
|
host: {
|
||||||
'[class]': "['filter-controls-panel']",
|
'[class]': "['filter-controls-panel']",
|
||||||
},
|
},
|
||||||
providers: [provideIcons({ isaActionSort, isaActionFilter })],
|
providers: [provideIcons({ isaActionSort, isaActionFilter })],
|
||||||
})
|
})
|
||||||
export class FilterControlsPanelComponent {
|
export class FilterControlsPanelComponent {
|
||||||
/**
|
/**
|
||||||
* Service for managing filter state and operations.
|
* Service for managing filter state and operations.
|
||||||
*/
|
*/
|
||||||
#filterService = inject(FilterService);
|
#filterService = inject(FilterService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The unique key identifier for this input in the filter system.
|
* The unique key identifier for this input in the filter system.
|
||||||
* @default 'qs'
|
* @default 'qs'
|
||||||
*/
|
*/
|
||||||
inputKey = signal('qs');
|
inputKey = signal('qs');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional array of switch filter configurations to display as toggle switches.
|
* Optional array of switch filter configurations to display as toggle switches.
|
||||||
* Each item should be a filter input with an associated icon.
|
* Each item should be a filter input with an associated icon.
|
||||||
* Switch filters are rendered to the left of the filter menu button.
|
* Switch filters are rendered to the left of the filter menu button.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* switchFilters = [
|
* switchFilters = [
|
||||||
* {
|
* {
|
||||||
* filter: availabilityFilter,
|
* filter: availabilityFilter,
|
||||||
* icon: 'isaActionCheck'
|
* icon: 'isaActionCheck'
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
switchFilters = input<Array<{ filter: FilterInput; icon: string }>>([]);
|
switchFilters = input<Array<{ filter: FilterInput; icon: string }>>([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Output event that emits when any search action is triggered.
|
* Output event that emits when any search action is triggered.
|
||||||
* Provides the specific SearchTrigger type to indicate how the search was initiated:
|
* Provides the specific SearchTrigger type to indicate how the search was initiated:
|
||||||
* - 'input': Text input or search button
|
* - 'input': Text input or search button
|
||||||
* - 'filter': Filter menu changes
|
* - 'filter': Filter menu changes
|
||||||
* - 'order-by': Sort order changes
|
* - 'order-by': Sort order changes
|
||||||
* - 'scan': Barcode scan
|
* - 'scan': Barcode scan
|
||||||
*/
|
*/
|
||||||
triggerSearch = output<SearchTrigger>();
|
triggerSearch = output<SearchTrigger>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signal tracking whether the viewport is at tablet size or above.
|
* Signal tracking whether the viewport is at tablet size or above.
|
||||||
* Used to determine responsive layout behavior for mobile vs desktop.
|
* Used to determine responsive layout behavior for mobile vs desktop.
|
||||||
*/
|
*/
|
||||||
mobileBreakpoint = breakpoint([Breakpoint.Tablet, Breakpoint.Desktop]);
|
mobileBreakpoint = breakpoint([Breakpoint.Tablet, Breakpoint.Desktop]);
|
||||||
|
|
||||||
/**
|
forceMobileLayout = input(false);
|
||||||
* Signal controlling the visibility of the order-by toolbar on mobile devices.
|
|
||||||
* Initially shows toolbar when NOT on mobile, can be toggled by user on mobile.
|
mobileLayout = computed(
|
||||||
* Linked to mobileBreakpoint to automatically adjust when screen size changes.
|
() => this.forceMobileLayout() || this.mobileBreakpoint(),
|
||||||
*/
|
);
|
||||||
showOrderByToolbarMobile = linkedSignal(() => !this.mobileBreakpoint());
|
|
||||||
|
/**
|
||||||
/**
|
* Signal controlling the visibility of the order-by toolbar on mobile devices.
|
||||||
* Computed signal that determines if the search input is present in the filter inputs.
|
* Initially shows toolbar when NOT on mobile, can be toggled by user on mobile.
|
||||||
* This checks if there is a TextFilterInput with the specified inputKey.
|
* Linked to mobileBreakpoint to automatically adjust when screen size changes.
|
||||||
* Used to conditionally render the search input in the template.
|
*/
|
||||||
*/
|
showOrderByToolbarMobile = linkedSignal(() => !this.mobileLayout());
|
||||||
hasInput = computed(() => {
|
|
||||||
const inputs = this.#filterService.inputs();
|
/**
|
||||||
const input = inputs.find(
|
* Computed signal that determines if the search input is present in the filter inputs.
|
||||||
(input) => input.key === this.inputKey() && input.type === InputType.Text,
|
* This checks if there is a TextFilterInput with the specified inputKey.
|
||||||
) as TextFilterInput;
|
* Used to conditionally render the search input in the template.
|
||||||
return !!input;
|
*/
|
||||||
});
|
hasInput = computed(() => {
|
||||||
|
const inputs = this.#filterService.inputs();
|
||||||
/**
|
const input = inputs.find(
|
||||||
* Computed signal that checks if there are any active filters applied.
|
(input) => input.key === this.inputKey() && input.type === InputType.Text,
|
||||||
* This is determined by checking if there are any inputs of types other than Text.
|
) as TextFilterInput;
|
||||||
*/
|
return !!input;
|
||||||
hasFilter = computed(() => {
|
});
|
||||||
const inputs = this.#filterService.inputs();
|
|
||||||
return inputs.some((input) => input.type !== InputType.Text);
|
/**
|
||||||
});
|
* Computed signal that checks if there are any active filters applied.
|
||||||
}
|
* This is determined by checking if there are any inputs of types other than Text.
|
||||||
|
*/
|
||||||
|
hasFilter = computed(() => {
|
||||||
|
const inputs = this.#filterService.inputs();
|
||||||
|
return inputs.some((input) => input.type !== InputType.Text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { Component } from '@angular/core';
|
|||||||
import {
|
import {
|
||||||
CheckboxComponent,
|
CheckboxComponent,
|
||||||
DropdownButtonComponent,
|
DropdownButtonComponent,
|
||||||
|
DropdownFilterComponent,
|
||||||
DropdownOptionComponent,
|
DropdownOptionComponent,
|
||||||
TextFieldComponent,
|
TextFieldComponent,
|
||||||
InputControlDirective,
|
InputControlDirective,
|
||||||
@@ -52,6 +53,7 @@ import { ReactiveFormsModule } from '@angular/forms';
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
CheckboxComponent,
|
CheckboxComponent,
|
||||||
DropdownButtonComponent,
|
DropdownButtonComponent,
|
||||||
|
DropdownFilterComponent,
|
||||||
DropdownOptionComponent,
|
DropdownOptionComponent,
|
||||||
TextFieldComponent,
|
TextFieldComponent,
|
||||||
InputControlDirective,
|
InputControlDirective,
|
||||||
@@ -92,6 +94,17 @@ export class MyFormComponent {}
|
|||||||
</ui-dropdown>
|
</ui-dropdown>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5. Dropdown with Filter
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ui-dropdown [(ngModel)]="selectedCountry" label="Select country">
|
||||||
|
<ui-dropdown-filter placeholder="Search..."></ui-dropdown-filter>
|
||||||
|
<ui-dropdown-option [value]="'de'">Germany</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'at'">Austria</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'ch'">Switzerland</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
```
|
||||||
|
|
||||||
## Core Concepts
|
## Core Concepts
|
||||||
|
|
||||||
### Component Categories
|
### Component Categories
|
||||||
@@ -119,6 +132,7 @@ The library provides three main categories of form controls:
|
|||||||
- **ChecklistValueDirective** - Value binding for checklist items
|
- **ChecklistValueDirective** - Value binding for checklist items
|
||||||
- **ChipOptionComponent** - Individual chip option
|
- **ChipOptionComponent** - Individual chip option
|
||||||
- **DropdownOptionComponent** - Individual dropdown option
|
- **DropdownOptionComponent** - Individual dropdown option
|
||||||
|
- **DropdownFilterComponent** - Filter input for dropdown options
|
||||||
- **ListboxItemDirective** - Individual listbox item
|
- **ListboxItemDirective** - Individual listbox item
|
||||||
|
|
||||||
### Control Value Accessor Pattern
|
### Control Value Accessor Pattern
|
||||||
@@ -194,6 +208,7 @@ A dropdown select component with keyboard navigation and CDK overlay integration
|
|||||||
- `showSelectedValue: boolean` - Show selected option text. Default: true
|
- `showSelectedValue: boolean` - Show selected option text. Default: true
|
||||||
- `tabIndex: number` - Tab index for keyboard navigation. Default: 0
|
- `tabIndex: number` - Tab index for keyboard navigation. Default: 0
|
||||||
- `id: string` - Optional element ID
|
- `id: string` - Optional element ID
|
||||||
|
- `equals: (a: T | null, b: T | null) => boolean` - Custom equality function for comparing option values. Default: `lodash.isEqual`
|
||||||
|
|
||||||
**Outputs:**
|
**Outputs:**
|
||||||
- `value: ModelSignal<T>` - Two-way bindable selected value
|
- `value: ModelSignal<T>` - Two-way bindable selected value
|
||||||
@@ -208,6 +223,7 @@ A dropdown select component with keyboard navigation and CDK overlay integration
|
|||||||
- `Arrow Down/Up` - Navigate through options
|
- `Arrow Down/Up` - Navigate through options
|
||||||
- `Enter` - Select highlighted option and close
|
- `Enter` - Select highlighted option and close
|
||||||
- `Escape` - Close dropdown
|
- `Escape` - Close dropdown
|
||||||
|
- `Space` - Toggle dropdown open/close (when focused)
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
```typescript
|
```typescript
|
||||||
@@ -236,6 +252,93 @@ selectedProduct: Product;
|
|||||||
</ui-dropdown>
|
</ui-dropdown>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Custom Equality Comparison:**
|
||||||
|
|
||||||
|
When working with object values, you may need custom comparison logic (e.g., comparing by ID instead of deep equality):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Compare products by ID instead of deep equality
|
||||||
|
readonly productEquals = (a: Product | null, b: Product | null) => a?.id === b?.id;
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ui-dropdown
|
||||||
|
[(value)]="selectedProduct"
|
||||||
|
[equals]="productEquals"
|
||||||
|
label="Select Product">
|
||||||
|
@for (product of products; track product.id) {
|
||||||
|
<ui-dropdown-option [value]="product">
|
||||||
|
{{ product.name }}
|
||||||
|
</ui-dropdown-option>
|
||||||
|
}
|
||||||
|
</ui-dropdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DropdownFilterComponent
|
||||||
|
|
||||||
|
A filter input component for use within a DropdownButtonComponent. Renders as a sticky input at the top of the options panel, allowing users to filter options by typing.
|
||||||
|
|
||||||
|
**Selector:** `ui-dropdown-filter`
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
- `placeholder: string` - Placeholder text for the filter input. Default: 'Suchen...'
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Sticky positioning at top of options panel
|
||||||
|
- Auto-focuses when dropdown opens
|
||||||
|
- Clears automatically when dropdown closes
|
||||||
|
- Arrow key navigation works while typing
|
||||||
|
- Enter key selects the highlighted option
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```html
|
||||||
|
<ui-dropdown [(ngModel)]="selectedCountry" label="Select country">
|
||||||
|
<ui-dropdown-filter placeholder="Search countries..."></ui-dropdown-filter>
|
||||||
|
<ui-dropdown-option [value]="'de'">Germany</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'at'">Austria</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'ch'">Switzerland</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'fr'">France</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'it'">Italy</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keyboard Navigation (when filter is focused):**
|
||||||
|
- `Arrow Down/Up` - Navigate through options
|
||||||
|
- `Enter` - Select highlighted option and close
|
||||||
|
- `Space` - Types in the input (does not toggle dropdown)
|
||||||
|
- `Escape` - Keeps focus in input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DropdownOptionComponent<T>
|
||||||
|
|
||||||
|
A selectable option component for use within a DropdownButtonComponent. Implements the CDK Highlightable interface for keyboard navigation support.
|
||||||
|
|
||||||
|
**Selector:** `ui-dropdown-option`
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
| Input | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `value` | `T` | Yes | - | The value associated with this option |
|
||||||
|
| `disabled` | `boolean` | No | `false` | Whether this option is disabled |
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```html
|
||||||
|
<ui-dropdown [(ngModel)]="selected">
|
||||||
|
<ui-dropdown-option [value]="'opt1'">Option 1</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'opt2'">Option 2</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'opt3'" [disabled]="true">Disabled</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Uses host dropdown's `equals` function for value comparison (defaults to `lodash.isEqual`)
|
||||||
|
- Automatic filtering support when used with `DropdownFilterComponent`
|
||||||
|
- CSS classes applied automatically: `active`, `selected`, `disabled`, `filtered`
|
||||||
|
- ARIA `role="option"` and `aria-selected` attributes for accessibility
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### ChipsComponent<T>
|
### ChipsComponent<T>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export * from './lib/checkbox/checklist-value.directive';
|
|||||||
export * from './lib/checkbox/checklist.component';
|
export * from './lib/checkbox/checklist.component';
|
||||||
export * from './lib/core/input-control.directive';
|
export * from './lib/core/input-control.directive';
|
||||||
export * from './lib/dropdown/dropdown.component';
|
export * from './lib/dropdown/dropdown.component';
|
||||||
|
export * from './lib/dropdown/dropdown-option.component';
|
||||||
|
export * from './lib/dropdown/dropdown-filter.component';
|
||||||
|
export * from './lib/dropdown/dropdown-host';
|
||||||
export * from './lib/dropdown/dropdown.types';
|
export * from './lib/dropdown/dropdown.types';
|
||||||
export * from './lib/listbox/listbox-item.directive';
|
export * from './lib/listbox/listbox-item.directive';
|
||||||
export * from './lib/listbox/listbox.directive';
|
export * from './lib/listbox/listbox.directive';
|
||||||
|
|||||||
@@ -1,118 +1,117 @@
|
|||||||
.ui-dropdown {
|
.ui-dropdown {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
padding: 0rem 1.5rem;
|
padding: 0rem 1.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 3.125rem;
|
border-radius: 3.125rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
ng-icon {
|
ng-icon {
|
||||||
@apply min-w-5 size-5 mt-[0.125rem];
|
@apply min-w-5 size-5 mt-[0.125rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
@apply text-isa-white bg-isa-neutral-400 border-isa-neutral-400 cursor-default;
|
@apply text-isa-white bg-isa-neutral-400 border-isa-neutral-400 cursor-default;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@apply bg-isa-neutral-400 border-isa-neutral-400;
|
@apply bg-isa-neutral-400 border-isa-neutral-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@apply border-isa-neutral-400;
|
@apply border-isa-neutral-400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dropdown__accent-outline {
|
.ui-dropdown__accent-outline {
|
||||||
@apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue;
|
@apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@apply bg-isa-neutral-100 border-isa-secondary-700;
|
@apply bg-isa-neutral-100 border-isa-secondary-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@apply border-isa-accent-blue;
|
@apply border-isa-accent-blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
@apply border-transparent;
|
@apply border-transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dropdown__grey {
|
.ui-dropdown__grey {
|
||||||
@apply text-isa-neutral-600 isa-text-body-2-bold bg-isa-neutral-400 border border-solid border-isa-neutral-400;
|
@apply text-isa-neutral-600 isa-text-body-2-bold bg-isa-neutral-400 border border-solid border-isa-neutral-400;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@apply bg-isa-neutral-500;
|
@apply bg-isa-neutral-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
&.has-value {
|
&.has-value {
|
||||||
@apply border-isa-accent-blue text-isa-accent-blue;
|
@apply border-isa-accent-blue text-isa-accent-blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
@apply border-isa-neutral-900 text-isa-neutral-900 bg-isa-white;
|
@apply border-isa-neutral-900 text-isa-neutral-900 bg-isa-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dropdown__text {
|
.ui-dropdown__text {
|
||||||
@apply overflow-hidden text-ellipsis whitespace-nowrap;
|
@apply overflow-hidden text-ellipsis whitespace-nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dropdown__options {
|
.ui-dropdown__options {
|
||||||
// Fixed typo from ui-dorpdown__options
|
@apply inline-flex flex-col items-start px-1 w-full max-h-80 overflow-hidden overflow-y-auto;
|
||||||
display: inline-flex;
|
@apply rounded-[1.25rem] bg-isa-white shadow-[0px_0px_16px_0px_rgba(0,0,0,0.15)];
|
||||||
padding: 0.25rem;
|
|
||||||
flex-direction: column;
|
.ui-dropdown__filter {
|
||||||
align-items: flex-start;
|
@apply sticky top-0 px-4 pt-6 pb-5 bg-isa-white list-none w-full;
|
||||||
border-radius: 1.25rem;
|
z-index: 1;
|
||||||
background: var(--Neutral-White, #fff);
|
}
|
||||||
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
|
|
||||||
width: 100%;
|
.ui-dropdown-option {
|
||||||
max-height: 20rem;
|
display: flex;
|
||||||
overflow: hidden;
|
width: 10rem;
|
||||||
overflow-y: auto;
|
height: 3rem;
|
||||||
|
min-height: 3rem;
|
||||||
.ui-dropdown-option {
|
padding: 0rem 1.5rem;
|
||||||
display: flex;
|
flex-direction: row;
|
||||||
width: 10rem;
|
justify-content: space-between;
|
||||||
height: 3rem;
|
align-items: center;
|
||||||
min-height: 3rem;
|
gap: 0.625rem;
|
||||||
padding: 0rem 1.5rem;
|
border-radius: 1rem;
|
||||||
flex-direction: row;
|
word-wrap: none;
|
||||||
justify-content: space-between;
|
white-space: nowrap;
|
||||||
align-items: center;
|
cursor: pointer;
|
||||||
gap: 0.625rem;
|
width: 100%;
|
||||||
border-radius: 1rem;
|
|
||||||
word-wrap: none;
|
@apply isa-text-body-2-bold;
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
&.active,
|
||||||
width: 100%;
|
&:focus,
|
||||||
|
&:hover {
|
||||||
@apply isa-text-body-2-bold;
|
@apply bg-isa-neutral-200;
|
||||||
|
}
|
||||||
&.active,
|
|
||||||
&:focus,
|
&.selected {
|
||||||
&:hover {
|
@apply text-isa-accent-blue;
|
||||||
@apply bg-isa-neutral-200;
|
}
|
||||||
}
|
|
||||||
|
&.disabled {
|
||||||
&.selected {
|
@apply text-isa-neutral-400;
|
||||||
@apply text-isa-accent-blue;
|
|
||||||
}
|
&.active,
|
||||||
|
&:focus,
|
||||||
&.disabled {
|
&:hover {
|
||||||
@apply text-isa-neutral-400;
|
@apply bg-isa-white;
|
||||||
|
}
|
||||||
&.active,
|
}
|
||||||
&:focus,
|
|
||||||
&:hover {
|
&.filtered {
|
||||||
@apply bg-isa-white;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<li class="ui-dropdown__filter">
|
||||||
|
<div class="relative flex items-center w-full">
|
||||||
|
<input
|
||||||
|
#filterInput
|
||||||
|
type="text"
|
||||||
|
[placeholder]="placeholder()"
|
||||||
|
[ngModel]="host.filter()"
|
||||||
|
(ngModelChange)="onFilterChange($event)"
|
||||||
|
class="bg-isa-neutral-200 w-full px-3 py-2 rounded isa-text-caption-regular text-isa-neutral-600 placeholder:text-isa-neutral-500 focus:outline-none focus:text-isa-neutral-900"
|
||||||
|
data-what="dropdown-filter-input"
|
||||||
|
aria-label="Filter options"
|
||||||
|
(keydown.escape)="$event.stopPropagation()"
|
||||||
|
(keydown.enter)="onEnterKey($event)"
|
||||||
|
(keydown.space)="$event.stopPropagation()"
|
||||||
|
(keydown.arrowDown)="onArrowKey($event)"
|
||||||
|
(keydown.arrowUp)="onArrowKey($event)"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
@if (host.filter()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="clearFilter($event)"
|
||||||
|
class="absolute top-0 right-0 bottom-0 px-2 flex items-center justify-center"
|
||||||
|
data-what="dropdown-filter-clear"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<ng-icon name="isaActionClose" class="text-isa-neutral-900" size="1rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||||
|
import { isaActionClose } from '@isa/icons';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filter input component for use within a DropdownButtonComponent.
|
||||||
|
* Renders as a sticky input at the top of the options panel.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```html
|
||||||
|
* <ui-dropdown [(ngModel)]="selected">
|
||||||
|
* <ui-dropdown-filter placeholder="Search..."></ui-dropdown-filter>
|
||||||
|
* <ui-dropdown-option [value]="'opt1'">Option 1</ui-dropdown-option>
|
||||||
|
* <ui-dropdown-option [value]="'opt2'">Option 2</ui-dropdown-option>
|
||||||
|
* </ui-dropdown>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-dropdown-filter',
|
||||||
|
templateUrl: './dropdown-filter.component.html',
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
imports: [FormsModule, NgIconComponent],
|
||||||
|
providers: [provideIcons({ isaActionClose })],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class DropdownFilterComponent {
|
||||||
|
#logger = logger({ component: 'DropdownFilterComponent' });
|
||||||
|
|
||||||
|
host = inject(DROPDOWN_HOST, { host: true }) as DropdownHost<unknown>;
|
||||||
|
|
||||||
|
filterInput = viewChild<ElementRef<HTMLInputElement>>('filterInput');
|
||||||
|
|
||||||
|
/** Placeholder text for the filter input */
|
||||||
|
placeholder = input<string>('Suchen...');
|
||||||
|
|
||||||
|
onFilterChange(value: string): void {
|
||||||
|
this.#logger.debug('Filter changed', () => ({ value }));
|
||||||
|
this.host.filter.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilter(event: Event): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.#logger.debug('Filter cleared');
|
||||||
|
this.host.filter.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forward arrow key events to the dropdown's keyManager for navigation */
|
||||||
|
onArrowKey(event: KeyboardEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.host.onKeydown(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle enter key to select the active option */
|
||||||
|
onEnterKey(event: KeyboardEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.host.selectActiveItem();
|
||||||
|
this.host.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus the filter input. Called by parent dropdown on open. */
|
||||||
|
focus(): void {
|
||||||
|
this.filterInput()?.nativeElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
libs/ui/input-controls/src/lib/dropdown/dropdown-host.ts
Normal file
25
libs/ui/input-controls/src/lib/dropdown/dropdown-host.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { InjectionToken, ModelSignal, Signal } from '@angular/core';
|
||||||
|
import type { DropdownOptionComponent } from './dropdown-option.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface representing the parent dropdown component.
|
||||||
|
* Used to avoid circular dependency with DropdownButtonComponent.
|
||||||
|
*/
|
||||||
|
export interface DropdownHost<T> {
|
||||||
|
value: ModelSignal<T | null>;
|
||||||
|
filter: ModelSignal<string>;
|
||||||
|
select: (option: DropdownOptionComponent<T>) => void;
|
||||||
|
selectActiveItem: () => void;
|
||||||
|
options: Signal<readonly DropdownOptionComponent<T>[]>;
|
||||||
|
close: () => void;
|
||||||
|
onKeydown: (event: KeyboardEvent) => void;
|
||||||
|
equals: Signal<(a: T | null, b: T | null) => boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection token for the parent dropdown component.
|
||||||
|
* The actual DropdownButtonComponent provides itself using this token.
|
||||||
|
*/
|
||||||
|
export const DROPDOWN_HOST = new InjectionToken<DropdownHost<unknown>>(
|
||||||
|
'DropdownButtonComponent',
|
||||||
|
);
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
|
||||||
|
import { DropdownButtonComponent } from './dropdown.component';
|
||||||
|
import { DropdownOptionComponent } from './dropdown-option.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test host component that wraps the dropdown with options
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-test-host',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||||
|
template: `
|
||||||
|
<ui-dropdown>
|
||||||
|
<ui-dropdown-option [value]="'option1'" [disabled]="option1Disabled">
|
||||||
|
Option 1
|
||||||
|
</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'option2'">Option 2</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'option3'">Option 3</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class TestHostComponent {
|
||||||
|
option1Disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DropdownOptionComponent', () => {
|
||||||
|
let spectator: Spectator<TestHostComponent>;
|
||||||
|
|
||||||
|
const createComponent = createComponentFactory({
|
||||||
|
component: TestHostComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spectator = createComponent();
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDropdown = () =>
|
||||||
|
spectator.debugElement
|
||||||
|
.query(By.directive(DropdownButtonComponent))
|
||||||
|
.componentInstance as DropdownButtonComponent<string>;
|
||||||
|
|
||||||
|
// Access options through dropdown's contentChildren signal (works before rendering)
|
||||||
|
const getOptions = () =>
|
||||||
|
getDropdown().options() as unknown as DropdownOptionComponent<string>[];
|
||||||
|
const getFirstOption = () => getOptions()[0];
|
||||||
|
|
||||||
|
// For DOM queries, we need to open the dropdown first (options render in CDK overlay)
|
||||||
|
const openAndGetOptionElements = () => {
|
||||||
|
getDropdown().open();
|
||||||
|
spectator.detectChanges();
|
||||||
|
// CDK overlay renders outside component tree, query from document
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll('ui-dropdown-option'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create options', () => {
|
||||||
|
expect(getOptions().length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Content Rendering', () => {
|
||||||
|
it('should render content via ng-content', () => {
|
||||||
|
const elements = openAndGetOptionElements();
|
||||||
|
expect(elements[0].textContent?.trim()).toBe('Option 1');
|
||||||
|
expect(elements[1].textContent?.trim()).toBe('Option 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return text content from getLabel()', () => {
|
||||||
|
openAndGetOptionElements(); // Need to render first for getLabel() to work
|
||||||
|
expect(getFirstOption().getLabel()).toBe('Option 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ARIA Attributes', () => {
|
||||||
|
it('should have role="option"', () => {
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
expect(element.getAttribute('role')).toBe('option');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have aria-selected="false" when not selected', () => {
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
expect(element.getAttribute('aria-selected')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have aria-selected="true" when selected', () => {
|
||||||
|
// Arrange - select first option
|
||||||
|
getDropdown().value.set('option1');
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(element.getAttribute('aria-selected')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have tabindex="-1"', () => {
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
expect(element.getAttribute('tabindex')).toBe('-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSS Classes', () => {
|
||||||
|
it('should have base class ui-dropdown-option', () => {
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
expect(element).toHaveClass('ui-dropdown-option');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "selected" class when option value matches dropdown value', () => {
|
||||||
|
// Arrange
|
||||||
|
getDropdown().value.set('option1');
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(element).toHaveClass('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have "selected" class when option value does not match', () => {
|
||||||
|
// Arrange
|
||||||
|
getDropdown().value.set('option2');
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(element).not.toHaveClass('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "disabled" class when disabled', () => {
|
||||||
|
// Arrange
|
||||||
|
spectator.component.option1Disabled = true;
|
||||||
|
spectator.detectChanges();
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(element).toHaveClass('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have "disabled" class when enabled', () => {
|
||||||
|
// Assert
|
||||||
|
const element = openAndGetOptionElements()[0];
|
||||||
|
expect(element).not.toHaveClass('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Active State (Keyboard Navigation)', () => {
|
||||||
|
it('should add "active" class when setActiveStyles() is called', () => {
|
||||||
|
// Arrange - open dropdown first to render options
|
||||||
|
const elements = openAndGetOptionElements();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getFirstOption().setActiveStyles();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(elements[0]).toHaveClass('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove "active" class when setInactiveStyles() is called', () => {
|
||||||
|
// Arrange
|
||||||
|
const elements = openAndGetOptionElements();
|
||||||
|
getFirstOption().setActiveStyles();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getFirstOption().setInactiveStyles();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(elements[0]).not.toHaveClass('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Selection Behavior', () => {
|
||||||
|
it('should update dropdown value when option is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
const selectSpy = jest.spyOn(dropdown, 'select');
|
||||||
|
const closeSpy = jest.spyOn(dropdown, 'close');
|
||||||
|
const elements = openAndGetOptionElements();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
elements[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(selectSpy).toHaveBeenCalledWith(getFirstOption());
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT call select when disabled option is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
spectator.component.option1Disabled = true;
|
||||||
|
spectator.detectChanges();
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
const selectSpy = jest.spyOn(dropdown, 'select');
|
||||||
|
const elements = openAndGetOptionElements();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
elements[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(selectSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Highlightable Interface', () => {
|
||||||
|
it('should implement setActiveStyles method', () => {
|
||||||
|
expect(typeof getFirstOption().setActiveStyles).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should implement setInactiveStyles method', () => {
|
||||||
|
expect(typeof getFirstOption().setInactiveStyles).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose disabled property for skip predicate', () => {
|
||||||
|
spectator.component.option1Disabled = true;
|
||||||
|
spectator.detectChanges();
|
||||||
|
expect(getFirstOption().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Value Comparison', () => {
|
||||||
|
it('should detect selection with primitive values', () => {
|
||||||
|
// Arrange
|
||||||
|
getDropdown().value.set('option1');
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getFirstOption().selected()).toBe(true);
|
||||||
|
expect(getOptions()[1].selected()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for object value comparison (deep equality)
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-test-host-objects',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||||
|
template: `
|
||||||
|
<ui-dropdown>
|
||||||
|
<ui-dropdown-option [value]="{ id: 1, name: 'First' }">
|
||||||
|
First
|
||||||
|
</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="{ id: 2, name: 'Second' }">
|
||||||
|
Second
|
||||||
|
</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class TestHostObjectsComponent {}
|
||||||
|
|
||||||
|
describe('DropdownOptionComponent with Object Values', () => {
|
||||||
|
let spectator: Spectator<TestHostObjectsComponent>;
|
||||||
|
|
||||||
|
const createComponent = createComponentFactory({
|
||||||
|
component: TestHostObjectsComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spectator = createComponent();
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDropdown = () =>
|
||||||
|
spectator.debugElement
|
||||||
|
.query(By.directive(DropdownButtonComponent))
|
||||||
|
.componentInstance as DropdownButtonComponent<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Access options through dropdown's contentChildren signal
|
||||||
|
const getOptions = () =>
|
||||||
|
getDropdown().options() as unknown as DropdownOptionComponent<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}>[];
|
||||||
|
|
||||||
|
it('should detect selection using deep equality for objects', () => {
|
||||||
|
// Arrange - set value to equal object (different reference)
|
||||||
|
getDropdown().value.set({ id: 1, name: 'First' });
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getOptions()[0].selected()).toBe(true);
|
||||||
|
expect(getOptions()[1].selected()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Highlightable } from '@angular/cdk/a11y';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selectable option component for use within a DropdownButtonComponent.
|
||||||
|
* Implements the CDK Highlightable interface for keyboard navigation support.
|
||||||
|
*
|
||||||
|
* @template T - The type of value this option holds
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```html
|
||||||
|
* <ui-dropdown [(ngModel)]="selected">
|
||||||
|
* <ui-dropdown-option [value]="'opt1'">Option 1</ui-dropdown-option>
|
||||||
|
* <ui-dropdown-option [value]="'opt2'">Option 2</ui-dropdown-option>
|
||||||
|
* <ui-dropdown-option [value]="'opt3'" [disabled]="true">Disabled</ui-dropdown-option>
|
||||||
|
* </ui-dropdown>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-dropdown-option',
|
||||||
|
template: '<ng-content></ng-content>',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'[class]':
|
||||||
|
'["ui-dropdown-option", activeClass(), selectedClass(), disabledClass(), filteredClass()]',
|
||||||
|
'role': 'option',
|
||||||
|
'[attr.aria-selected]': 'selected()',
|
||||||
|
'[attr.tabindex]': '-1',
|
||||||
|
'(click)': 'select()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DropdownOptionComponent<T> implements Highlightable {
|
||||||
|
/**
|
||||||
|
* Reference to the parent dropdown component.
|
||||||
|
* Injected from the host element to find the parent DropdownButtonComponent.
|
||||||
|
*/
|
||||||
|
private host = inject(DROPDOWN_HOST, { host: true }) as DropdownHost<T>;
|
||||||
|
|
||||||
|
private elementRef = inject(ElementRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal signal tracking the active (keyboard-highlighted) state.
|
||||||
|
* Used by the CDK ActiveDescendantKeyManager for keyboard navigation.
|
||||||
|
*/
|
||||||
|
active = signal(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal input for the disabled state.
|
||||||
|
* Uses alias to allow getter for CDK Highlightable interface compatibility.
|
||||||
|
* The CDK ActiveDescendantKeyManager expects a plain `disabled` boolean property,
|
||||||
|
* but signal inputs return Signal<T>. The alias + getter pattern satisfies both.
|
||||||
|
*/
|
||||||
|
readonly disabledInput = input<boolean>(false, { alias: 'disabled' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this option is disabled and cannot be selected.
|
||||||
|
* Getter provides CDK Highlightable interface compatibility (expects plain boolean).
|
||||||
|
*/
|
||||||
|
get disabled(): boolean {
|
||||||
|
return this.disabledInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS class applied when the option is disabled.
|
||||||
|
*/
|
||||||
|
disabledClass = computed(() => (this.disabledInput() ? 'disabled' : ''));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS class applied when the option is actively highlighted via keyboard.
|
||||||
|
*/
|
||||||
|
activeClass = computed(() => (this.active() ? 'active' : ''));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the active (keyboard-highlighted) styles.
|
||||||
|
* Part of the Highlightable interface for CDK keyboard navigation.
|
||||||
|
*/
|
||||||
|
setActiveStyles(): void {
|
||||||
|
this.active.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the active (keyboard-highlighted) styles.
|
||||||
|
* Part of the Highlightable interface for CDK keyboard navigation.
|
||||||
|
*/
|
||||||
|
setInactiveStyles(): void {
|
||||||
|
this.active.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the text label from the option's projected content.
|
||||||
|
* Used by the parent dropdown to display the selected value.
|
||||||
|
*/
|
||||||
|
getLabel(): string {
|
||||||
|
return this.elementRef.nativeElement.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal indicating whether this option is currently selected.
|
||||||
|
* Uses deep equality comparison to support object values.
|
||||||
|
*/
|
||||||
|
selected = computed(() => {
|
||||||
|
const hostValue = this.host.value();
|
||||||
|
const optionValue = this.value();
|
||||||
|
return this.host.equals()(hostValue, optionValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS class applied when the option is selected.
|
||||||
|
*/
|
||||||
|
selectedClass = computed(() => (this.selected() ? 'selected' : ''));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this option is hidden by the filter.
|
||||||
|
* Compares the filter text against the option's label (case-insensitive).
|
||||||
|
*/
|
||||||
|
isFiltered = computed(() => {
|
||||||
|
const filterText = this.host.filter().toLowerCase();
|
||||||
|
if (!filterText) return false;
|
||||||
|
return !this.getLabel().toLowerCase().includes(filterText);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS class applied when the option is filtered out.
|
||||||
|
*/
|
||||||
|
filteredClass = computed(() => (this.isFiltered() ? 'filtered' : ''));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value associated with this option.
|
||||||
|
* This is compared against the parent dropdown's value to determine selection.
|
||||||
|
*/
|
||||||
|
value = input.required<T>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles click events to select this option.
|
||||||
|
* Does nothing if the option is disabled.
|
||||||
|
*/
|
||||||
|
select(): void {
|
||||||
|
if (this.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.host.select(this);
|
||||||
|
this.host.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
<span [class]="['ui-dropdown__text']">{{ viewLabel() }}</span>
|
<span [class]="['ui-dropdown__text']">{{ viewLabel() }}</span>
|
||||||
<ng-icon [name]="isOpenIcon()"></ng-icon>
|
<ng-icon [name]="isOpenIcon()"></ng-icon>
|
||||||
|
|
||||||
<ng-template
|
<ng-template
|
||||||
cdkConnectedOverlay
|
cdkConnectedOverlay
|
||||||
[cdkConnectedOverlayOrigin]="cdkOverlayOrigin"
|
[cdkConnectedOverlayOrigin]="cdkOverlayOrigin"
|
||||||
[cdkConnectedOverlayOpen]="isOpen()"
|
[cdkConnectedOverlayOpen]="isOpen()"
|
||||||
[cdkConnectedOverlayOffsetY]="12"
|
[cdkConnectedOverlayPositions]="overlayPositions"
|
||||||
[cdkConnectedOverlayHasBackdrop]="true"
|
[cdkConnectedOverlayHasBackdrop]="true"
|
||||||
[cdkConnectedOverlayDisableClose]="false"
|
[cdkConnectedOverlayDisableClose]="false"
|
||||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||||
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
||||||
[cdkConnectedOverlayLockPosition]="true"
|
[cdkConnectedOverlayLockPosition]="true"
|
||||||
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
||||||
(backdropClick)="close()"
|
(backdropClick)="close()"
|
||||||
(detach)="isOpen.set(false)"
|
(detach)="isOpen.set(false)"
|
||||||
>
|
>
|
||||||
<ul #optionsPanel [class]="['ui-dropdown__options']" role="listbox">
|
<ul #optionsPanel [class]="['ui-dropdown__options']" role="listbox">
|
||||||
<ng-content></ng-content>
|
<ng-content select="ui-dropdown-filter"></ng-content>
|
||||||
</ul>
|
<ng-content></ng-content>
|
||||||
</ng-template>
|
</ul>
|
||||||
|
</ng-template>
|
||||||
|
|||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||||
|
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { DropdownButtonComponent } from './dropdown.component';
|
||||||
|
import { DropdownOptionComponent } from './dropdown-option.component';
|
||||||
|
import { DropdownAppearance } from './dropdown.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test host component with basic dropdown
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-test-host',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
DropdownButtonComponent,
|
||||||
|
DropdownOptionComponent,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<ui-dropdown
|
||||||
|
[appearance]="appearance"
|
||||||
|
[label]="label"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[showSelectedValue]="showSelectedValue"
|
||||||
|
[(ngModel)]="selectedValue"
|
||||||
|
>
|
||||||
|
<ui-dropdown-option [value]="'option1'">Option 1</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'option2'">Option 2</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="'option3'" [disabled]="true">
|
||||||
|
Disabled Option
|
||||||
|
</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class TestHostComponent {
|
||||||
|
appearance = DropdownAppearance.AccentOutline;
|
||||||
|
label = 'Select an option';
|
||||||
|
disabled = false;
|
||||||
|
showSelectedValue = true;
|
||||||
|
selectedValue: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DropdownButtonComponent', () => {
|
||||||
|
let spectator: Spectator<TestHostComponent>;
|
||||||
|
|
||||||
|
const createComponent = createComponentFactory({
|
||||||
|
component: TestHostComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spectator = createComponent();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up any open overlays
|
||||||
|
document.querySelectorAll('.cdk-overlay-container').forEach((el) => {
|
||||||
|
el.innerHTML = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDropdown = () =>
|
||||||
|
spectator.debugElement
|
||||||
|
.query(By.directive(DropdownButtonComponent))
|
||||||
|
.componentInstance as DropdownButtonComponent<string>;
|
||||||
|
|
||||||
|
const getDropdownElement = () =>
|
||||||
|
spectator.query('ui-dropdown') as HTMLElement;
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
getDropdown().open();
|
||||||
|
spectator.detectChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionElements = () =>
|
||||||
|
Array.from(
|
||||||
|
document.querySelectorAll('ui-dropdown-option'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(getDropdown()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Open/Close Behavior', () => {
|
||||||
|
it('should open dropdown on click', () => {
|
||||||
|
// Act
|
||||||
|
spectator.click(getDropdownElement());
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().isOpen()).toBe(true);
|
||||||
|
expect(getOptionElements().length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close dropdown on second click', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
expect(getDropdown().isOpen()).toBe(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
spectator.click(getDropdownElement());
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close dropdown on Escape key', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
spectator.dispatchKeyboardEvent(getDropdownElement(), 'keydown', 'Escape');
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close dropdown when option is selected', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getOptionElements()[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT open when disabled', () => {
|
||||||
|
// Arrange
|
||||||
|
spectator.component.disabled = true;
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
spectator.click(getDropdownElement());
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ARIA Attributes', () => {
|
||||||
|
it('should have role="combobox"', () => {
|
||||||
|
expect(getDropdownElement().getAttribute('role')).toBe('combobox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have aria-haspopup="listbox"', () => {
|
||||||
|
expect(getDropdownElement().getAttribute('aria-haspopup')).toBe(
|
||||||
|
'listbox',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: aria-expanded is incorrectly bound in the original code as a literal string
|
||||||
|
// This will be fixed during refactoring
|
||||||
|
it('should have aria-expanded attribute', () => {
|
||||||
|
// Currently bound incorrectly as literal string - see host binding
|
||||||
|
// The attribute should dynamically reflect isOpen() state
|
||||||
|
expect(getDropdownElement().hasAttribute('aria-expanded')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have tabindex="0" by default', () => {
|
||||||
|
expect(getDropdownElement().getAttribute('tabindex')).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have tabindex="-1" when disabled', () => {
|
||||||
|
// Arrange
|
||||||
|
spectator.component.disabled = true;
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdownElement().getAttribute('tabindex')).toBe('-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSS Classes', () => {
|
||||||
|
it('should have base class ui-dropdown', () => {
|
||||||
|
expect(getDropdownElement()).toHaveClass('ui-dropdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have accent-outline appearance by default', () => {
|
||||||
|
expect(getDropdownElement()).toHaveClass('ui-dropdown__accent-outline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply grey appearance', () => {
|
||||||
|
// Arrange
|
||||||
|
(spectator.component as { appearance: string }).appearance =
|
||||||
|
DropdownAppearance.Grey;
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdownElement()).toHaveClass('ui-dropdown__grey');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "open" class when open', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdownElement()).toHaveClass('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "disabled" class when disabled', () => {
|
||||||
|
// Arrange
|
||||||
|
spectator.component.disabled = true;
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdownElement()).toHaveClass('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "has-value" class when value is set', fakeAsync(() => {
|
||||||
|
// Arrange - set value directly on dropdown component since ngModel needs time
|
||||||
|
getDropdown().value.set('option1');
|
||||||
|
spectator.detectChanges();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdownElement()).toHaveClass('has-value');
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Label Display', () => {
|
||||||
|
it('should display label when no value selected', () => {
|
||||||
|
const labelElement = spectator.query('.ui-dropdown__text');
|
||||||
|
expect(labelElement?.textContent?.trim()).toBe('Select an option');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display selected option label when showSelectedValue is true and no label set', fakeAsync(() => {
|
||||||
|
// viewLabel logic: label() ?? selectedOption.getLabel()
|
||||||
|
// So we need to clear the label to see the selected option's label
|
||||||
|
spectator.component.label = undefined as unknown as string;
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Arrange - select an option
|
||||||
|
openDropdown();
|
||||||
|
getOptionElements()[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
// Assert - with no label set, viewLabel returns selectedOption.getLabel()
|
||||||
|
const labelElement = spectator.query('.ui-dropdown__text');
|
||||||
|
expect(labelElement?.textContent?.trim()).toBe('Option 1');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should display original label when showSelectedValue is false', fakeAsync(() => {
|
||||||
|
// Arrange
|
||||||
|
spectator.component.showSelectedValue = false;
|
||||||
|
spectator.detectChanges();
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getOptionElements()[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const labelElement = spectator.query('.ui-dropdown__text');
|
||||||
|
expect(labelElement?.textContent?.trim()).toBe('Select an option');
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ControlValueAccessor', () => {
|
||||||
|
it('should implement writeValue correctly', () => {
|
||||||
|
// Act
|
||||||
|
getDropdown().writeValue('option2');
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().value()).toBe('option2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChange when value changes', () => {
|
||||||
|
// Arrange
|
||||||
|
const onChangeSpy = jest.fn();
|
||||||
|
getDropdown().registerOnChange(onChangeSpy);
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getOptionElements()[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(onChangeSpy).toHaveBeenCalledWith('option1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onTouched when value changes', () => {
|
||||||
|
// Arrange
|
||||||
|
const onTouchedSpy = jest.fn();
|
||||||
|
getDropdown().registerOnTouched(onTouchedSpy);
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getOptionElements()[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(onTouchedSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set disabled state', () => {
|
||||||
|
// Act
|
||||||
|
getDropdown().setDisabledState?.(true);
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().disabled()).toBe(true);
|
||||||
|
expect(getDropdownElement()).toHaveClass('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update host component value via ngModel', fakeAsync(() => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getOptionElements()[1].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(spectator.component.selectedValue).toBe('option2');
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keyboard Navigation', () => {
|
||||||
|
it('should navigate down with ArrowDown', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
spectator.dispatchKeyboardEvent(
|
||||||
|
getDropdownElement(),
|
||||||
|
'keydown',
|
||||||
|
'ArrowDown',
|
||||||
|
);
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert - keyManager should have active item
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((dropdown as any)['keyManager']?.activeItem).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip disabled options', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
|
||||||
|
// Navigate to second option
|
||||||
|
spectator.dispatchKeyboardEvent(
|
||||||
|
getDropdownElement(),
|
||||||
|
'keydown',
|
||||||
|
'ArrowDown',
|
||||||
|
);
|
||||||
|
spectator.dispatchKeyboardEvent(
|
||||||
|
getDropdownElement(),
|
||||||
|
'keydown',
|
||||||
|
'ArrowDown',
|
||||||
|
);
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Should skip the disabled third option and wrap to first
|
||||||
|
// (keyManager has withWrap() enabled)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((dropdown as any)['keyManager']?.activeItem?.disabled).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select option on Enter', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
|
||||||
|
// Navigate to first option
|
||||||
|
spectator.dispatchKeyboardEvent(
|
||||||
|
getDropdownElement(),
|
||||||
|
'keydown',
|
||||||
|
'ArrowDown',
|
||||||
|
);
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Act - press Enter
|
||||||
|
spectator.dispatchKeyboardEvent(
|
||||||
|
getDropdownElement(),
|
||||||
|
'keydown',
|
||||||
|
'Enter',
|
||||||
|
);
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dropdown.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open dropdown on Space key when closed', () => {
|
||||||
|
// Arrange
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
expect(dropdown.isOpen()).toBe(false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
spectator.dispatchKeyboardEvent(getDropdownElement(), 'keydown', ' ');
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dropdown.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close dropdown on Space key when open', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
expect(dropdown.isOpen()).toBe(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
spectator.dispatchKeyboardEvent(getDropdownElement(), 'keydown', ' ');
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dropdown.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select active option on Space key when open', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
const dropdown = getDropdown();
|
||||||
|
|
||||||
|
// Navigate to first option
|
||||||
|
spectator.dispatchKeyboardEvent(
|
||||||
|
getDropdownElement(),
|
||||||
|
'keydown',
|
||||||
|
'ArrowDown',
|
||||||
|
);
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Act - press Space
|
||||||
|
spectator.dispatchKeyboardEvent(getDropdownElement(), 'keydown', ' ');
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert - dropdown should be closed and value selected
|
||||||
|
expect(dropdown.isOpen()).toBe(false);
|
||||||
|
expect(dropdown.value()).toBe('option1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent default scroll behavior on Space key', () => {
|
||||||
|
// Arrange
|
||||||
|
const event = new KeyboardEvent('keydown', { key: ' ' });
|
||||||
|
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
getDropdown().onSpaceKey(event);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Icon Display', () => {
|
||||||
|
it('should show chevron down when closed', () => {
|
||||||
|
expect(getDropdown().isOpenIcon()).toBe('isaActionChevronDown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show chevron up when open', () => {
|
||||||
|
// Arrange
|
||||||
|
openDropdown();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().isOpenIcon()).toBe('isaActionChevronUp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test with FormControl
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-test-host-form-control',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
DropdownButtonComponent,
|
||||||
|
DropdownOptionComponent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<ui-dropdown [formControl]="control">
|
||||||
|
<ui-dropdown-option [value]="1">One</ui-dropdown-option>
|
||||||
|
<ui-dropdown-option [value]="2">Two</ui-dropdown-option>
|
||||||
|
</ui-dropdown>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class TestHostFormControlComponent {
|
||||||
|
control = new FormControl<number | null>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DropdownButtonComponent with FormControl', () => {
|
||||||
|
let spectator: Spectator<TestHostFormControlComponent>;
|
||||||
|
|
||||||
|
const createComponent = createComponentFactory({
|
||||||
|
component: TestHostFormControlComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spectator = createComponent();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelectorAll('.cdk-overlay-container').forEach((el) => {
|
||||||
|
el.innerHTML = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDropdown = () =>
|
||||||
|
spectator.debugElement
|
||||||
|
.query(By.directive(DropdownButtonComponent))
|
||||||
|
.componentInstance as DropdownButtonComponent<number>;
|
||||||
|
|
||||||
|
it('should sync with FormControl value', () => {
|
||||||
|
// Act
|
||||||
|
spectator.component.control.setValue(2);
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().value()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update FormControl when option selected', () => {
|
||||||
|
// Arrange
|
||||||
|
getDropdown().open();
|
||||||
|
spectator.detectChanges();
|
||||||
|
const options = Array.from(
|
||||||
|
document.querySelectorAll('ui-dropdown-option'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
options[0].click();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(spectator.component.control.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable dropdown when FormControl is disabled', () => {
|
||||||
|
// Act
|
||||||
|
spectator.component.control.disable();
|
||||||
|
spectator.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(getDropdown().disabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,286 +1,371 @@
|
|||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
contentChildren,
|
contentChild,
|
||||||
effect,
|
contentChildren,
|
||||||
ElementRef,
|
effect,
|
||||||
inject,
|
ElementRef,
|
||||||
Input,
|
inject,
|
||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
signal,
|
Signal,
|
||||||
viewChild,
|
signal,
|
||||||
} from '@angular/core';
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
||||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
import { isEqual } from 'lodash';
|
||||||
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
|
||||||
import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
import {
|
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||||
CdkConnectedOverlay,
|
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
||||||
CdkOverlayOrigin,
|
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
|
||||||
ScrollStrategyOptions,
|
import {
|
||||||
} from '@angular/cdk/overlay';
|
CdkConnectedOverlay,
|
||||||
import { isEqual } from 'lodash';
|
CdkOverlayOrigin,
|
||||||
import { DropdownAppearance } from './dropdown.types';
|
ConnectedPosition,
|
||||||
import { DropdownService } from './dropdown.service';
|
ScrollStrategyOptions,
|
||||||
import { CloseOnScrollDirective } from '@isa/ui/layout';
|
} from '@angular/cdk/overlay';
|
||||||
|
import { DropdownAppearance } from './dropdown.types';
|
||||||
@Component({
|
import { DropdownService } from './dropdown.service';
|
||||||
selector: 'ui-dropdown-option',
|
import { CloseOnScrollDirective } from '@isa/ui/layout';
|
||||||
template: '<ng-content></ng-content>',
|
import { logger } from '@isa/core/logging';
|
||||||
host: {
|
|
||||||
'[class]':
|
import { DropdownOptionComponent } from './dropdown-option.component';
|
||||||
'["ui-dropdown-option", activeClass(), selectedClass(), disabledClass()]',
|
import { DropdownFilterComponent } from './dropdown-filter.component';
|
||||||
'role': 'option',
|
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
|
||||||
'[attr.aria-selected]': 'selected()',
|
|
||||||
'[attr.tabindex]': '-1',
|
@Component({
|
||||||
'(click)': 'select()',
|
selector: 'ui-dropdown',
|
||||||
},
|
templateUrl: './dropdown.component.html',
|
||||||
})
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
export class DropdownOptionComponent<T> implements Highlightable {
|
hostDirectives: [
|
||||||
private host = inject(DropdownButtonComponent<T>);
|
CdkOverlayOrigin,
|
||||||
private elementRef = inject(ElementRef);
|
{
|
||||||
|
directive: CloseOnScrollDirective,
|
||||||
active = signal(false);
|
outputs: ['closeOnScroll'],
|
||||||
|
},
|
||||||
readonly _disabled = signal<boolean>(false);
|
],
|
||||||
|
imports: [NgIconComponent, CdkConnectedOverlay],
|
||||||
@Input()
|
providers: [
|
||||||
get disabled(): boolean {
|
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
|
||||||
return this._disabled();
|
{
|
||||||
}
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: DropdownButtonComponent,
|
||||||
set disabled(value: boolean) {
|
multi: true,
|
||||||
this._disabled.set(value);
|
},
|
||||||
}
|
// Provide self for child DropdownOptionComponent to inject
|
||||||
|
{
|
||||||
disabledClass = computed(() => (this.disabled ? 'disabled' : ''));
|
provide: DROPDOWN_HOST,
|
||||||
|
useExisting: DropdownButtonComponent,
|
||||||
activeClass = computed(() => (this.active() ? 'active' : ''));
|
},
|
||||||
|
],
|
||||||
setActiveStyles(): void {
|
host: {
|
||||||
this.active.set(true);
|
'[class]':
|
||||||
}
|
'["ui-dropdown", appearanceClass(), isOpenClass(), disabledClass(), valueClass()]',
|
||||||
|
'role': 'combobox',
|
||||||
setInactiveStyles(): void {
|
'aria-haspopup': 'listbox',
|
||||||
this.active.set(false);
|
'[attr.id]': 'id()',
|
||||||
}
|
'[attr.tabindex]': 'disabled() ? -1 : tabIndex()',
|
||||||
|
'aria-expanded': 'isOpen()',
|
||||||
getLabel(): string {
|
'(keydown)': 'keyManager?.onKeydown($event)',
|
||||||
return this.elementRef.nativeElement.textContent.trim();
|
'(keydown.enter)': 'select(keyManager!.activeItem); close()',
|
||||||
}
|
'(keydown.escape)': 'close()',
|
||||||
|
'(keydown.space)': 'onSpaceKey($event)',
|
||||||
selected = computed(() => {
|
'(click)':
|
||||||
const hostValue = this.host.value();
|
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
|
||||||
const value = this.value();
|
'(closeOnScroll)': 'close()',
|
||||||
return hostValue === value || isEqual(hostValue, value);
|
},
|
||||||
});
|
})
|
||||||
|
export class DropdownButtonComponent<T>
|
||||||
selectedClass = computed(() => (this.selected() ? 'selected' : ''));
|
implements ControlValueAccessor, AfterViewInit, DropdownHost<T>
|
||||||
|
{
|
||||||
value = input.required<T>();
|
#logger = logger({ component: 'DropdownButtonComponent' });
|
||||||
|
#dropdownService = inject(DropdownService);
|
||||||
select() {
|
#scrollStrategy = inject(ScrollStrategyOptions);
|
||||||
if (this.disabled) {
|
#closeOnScroll = inject(CloseOnScrollDirective, { self: true });
|
||||||
return;
|
|
||||||
}
|
readonly init = signal(false);
|
||||||
this.host.select(this);
|
private elementRef = inject(ElementRef);
|
||||||
this.host.close();
|
|
||||||
}
|
/** Reference to the options panel for scroll exclusion */
|
||||||
}
|
optionsPanel = viewChild<ElementRef<HTMLElement>>('optionsPanel');
|
||||||
|
|
||||||
@Component({
|
get overlayMinWidth() {
|
||||||
selector: 'ui-dropdown',
|
return this.elementRef.nativeElement.offsetWidth;
|
||||||
templateUrl: './dropdown.component.html',
|
}
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
hostDirectives: [
|
get blockScrollStrategy() {
|
||||||
CdkOverlayOrigin,
|
return this.#scrollStrategy.block();
|
||||||
{
|
}
|
||||||
directive: CloseOnScrollDirective,
|
|
||||||
outputs: ['closeOnScroll'],
|
/** Offset in pixels between the trigger and the overlay panel */
|
||||||
},
|
readonly #overlayOffset = 12;
|
||||||
],
|
|
||||||
imports: [NgIconComponent, CdkConnectedOverlay],
|
/**
|
||||||
providers: [
|
* Position priority for the overlay panel.
|
||||||
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
|
* Order: bottom-left, bottom-right, top-left, top-right,
|
||||||
{
|
* right-top, right-bottom, left-top, left-bottom
|
||||||
provide: NG_VALUE_ACCESSOR,
|
*/
|
||||||
useExisting: DropdownButtonComponent,
|
readonly overlayPositions: ConnectedPosition[] = [
|
||||||
multi: true,
|
// Bottom left
|
||||||
},
|
{
|
||||||
],
|
originX: 'start',
|
||||||
host: {
|
originY: 'bottom',
|
||||||
'[class]':
|
overlayX: 'start',
|
||||||
'["ui-dropdown", appearanceClass(), isOpenClass(), disabledClass(), valueClass()]',
|
overlayY: 'top',
|
||||||
'role': 'combobox',
|
offsetY: this.#overlayOffset,
|
||||||
'aria-haspopup': 'listbox',
|
},
|
||||||
'[attr.id]': 'id()',
|
// Bottom right
|
||||||
'[attr.tabindex]': 'disabled() ? -1 : tabIndex()',
|
{
|
||||||
'aria-expanded': 'isOpen()',
|
originX: 'end',
|
||||||
'(keydown)': 'keyManger?.onKeydown($event)',
|
originY: 'bottom',
|
||||||
'(keydown.enter)': 'select(keyManger.activeItem); close()',
|
overlayX: 'end',
|
||||||
'(keydown.escape)': 'close()',
|
overlayY: 'top',
|
||||||
'(click)':
|
offsetY: this.#overlayOffset,
|
||||||
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
|
},
|
||||||
'(closeOnScroll)': 'close()',
|
// Top left
|
||||||
},
|
{
|
||||||
})
|
originX: 'start',
|
||||||
export class DropdownButtonComponent<T>
|
originY: 'top',
|
||||||
implements ControlValueAccessor, AfterViewInit
|
overlayX: 'start',
|
||||||
{
|
overlayY: 'bottom',
|
||||||
#dropdownService = inject(DropdownService);
|
offsetY: -this.#overlayOffset,
|
||||||
#scrollStrategy = inject(ScrollStrategyOptions);
|
},
|
||||||
#closeOnScroll = inject(CloseOnScrollDirective, { self: true });
|
// Top right
|
||||||
|
{
|
||||||
readonly init = signal(false);
|
originX: 'end',
|
||||||
private elementRef = inject(ElementRef);
|
originY: 'top',
|
||||||
|
overlayX: 'end',
|
||||||
/** Reference to the options panel for scroll exclusion */
|
overlayY: 'bottom',
|
||||||
optionsPanel = viewChild<ElementRef<HTMLElement>>('optionsPanel');
|
offsetY: -this.#overlayOffset,
|
||||||
|
},
|
||||||
get overlayMinWidth() {
|
// Right top
|
||||||
return this.elementRef.nativeElement.offsetWidth;
|
{
|
||||||
}
|
originX: 'end',
|
||||||
|
originY: 'top',
|
||||||
get blockScrollStrategy() {
|
overlayX: 'start',
|
||||||
return this.#scrollStrategy.block();
|
overlayY: 'top',
|
||||||
}
|
offsetX: this.#overlayOffset,
|
||||||
|
},
|
||||||
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
// Right bottom
|
||||||
|
{
|
||||||
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
|
originX: 'end',
|
||||||
|
originY: 'bottom',
|
||||||
disabledClass = computed(() => (this.disabled() ? 'disabled' : ''));
|
overlayX: 'start',
|
||||||
|
overlayY: 'bottom',
|
||||||
valueClass = computed(() => (this.value() ? 'has-value' : ''));
|
offsetX: this.#overlayOffset,
|
||||||
|
},
|
||||||
id = input<string>();
|
// Left top
|
||||||
|
{
|
||||||
value = model<T>();
|
originX: 'start',
|
||||||
|
originY: 'top',
|
||||||
tabIndex = input<number>(0);
|
overlayX: 'end',
|
||||||
|
overlayY: 'top',
|
||||||
label = input<string>();
|
offsetX: -this.#overlayOffset,
|
||||||
|
},
|
||||||
disabled = model<boolean>(false);
|
// Left bottom
|
||||||
|
{
|
||||||
showSelectedValue = input<boolean>(true);
|
originX: 'start',
|
||||||
|
originY: 'bottom',
|
||||||
options = contentChildren(DropdownOptionComponent);
|
overlayX: 'end',
|
||||||
|
overlayY: 'bottom',
|
||||||
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
|
offsetX: -this.#overlayOffset,
|
||||||
|
},
|
||||||
selectedOption = computed(() => {
|
];
|
||||||
const options = this.options();
|
|
||||||
if (!options) {
|
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
||||||
return undefined;
|
|
||||||
}
|
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
|
||||||
|
|
||||||
return options.find((option) => option.value() === this.value());
|
disabledClass = computed(() => (this.disabled() ? 'disabled' : ''));
|
||||||
});
|
|
||||||
|
valueClass = computed(() => (this.value() ? 'has-value' : ''));
|
||||||
private keyManger?: ActiveDescendantKeyManager<DropdownOptionComponent<T>>;
|
|
||||||
|
id = input<string>();
|
||||||
onChange?: (value: T) => void;
|
|
||||||
|
value = model<T | null>(null);
|
||||||
onTouched?: () => void;
|
|
||||||
|
filter = model<string>('');
|
||||||
isOpen = signal(false);
|
|
||||||
|
tabIndex = input<number>(0);
|
||||||
isOpenClass = computed(() => (this.isOpen() ? 'open' : ''));
|
|
||||||
|
label = input<string>();
|
||||||
isOpenIcon = computed(() =>
|
|
||||||
this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown',
|
disabled = model<boolean>(false);
|
||||||
);
|
|
||||||
|
showSelectedValue = input<boolean>(true);
|
||||||
viewLabel = computed(() => {
|
|
||||||
if (!this.showSelectedValue()) {
|
equals = input(isEqual);
|
||||||
return this.label() ?? this.value();
|
|
||||||
}
|
options = contentChildren(DropdownOptionComponent);
|
||||||
|
|
||||||
const selectedOption = this.selectedOption();
|
/** Optional filter component projected as content */
|
||||||
|
filterComponent = contentChild(DropdownFilterComponent);
|
||||||
if (!selectedOption) {
|
|
||||||
return this.label() ?? this.value();
|
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
|
||||||
}
|
|
||||||
|
selectedOption = computed(() => {
|
||||||
return this.label() ?? selectedOption.getLabel();
|
const options = this.options();
|
||||||
});
|
if (!options) {
|
||||||
|
return undefined;
|
||||||
constructor() {
|
}
|
||||||
effect(() => {
|
const currentValue = this.value();
|
||||||
if (!this.init()) {
|
const equalsFn = this.equals();
|
||||||
return;
|
return options.find((option) => equalsFn(option.value(), currentValue));
|
||||||
}
|
});
|
||||||
this.keyManger?.destroy();
|
|
||||||
this.keyManger = new ActiveDescendantKeyManager<
|
keyManager?: ActiveDescendantKeyManager<DropdownOptionComponent<T>>;
|
||||||
DropdownOptionComponent<T>
|
|
||||||
>(this.options())
|
onChange?: (value: T | null) => void;
|
||||||
.withWrap()
|
|
||||||
.skipPredicate((option) => option.disabled);
|
onTouched?: () => void;
|
||||||
});
|
|
||||||
|
isOpen = signal(false);
|
||||||
// Configure CloseOnScrollDirective: activate when open, exclude options panel
|
|
||||||
effect(() => {
|
isOpenClass = computed(() => (this.isOpen() ? 'open' : ''));
|
||||||
this.#closeOnScroll.closeOnScrollWhen.set(this.isOpen());
|
|
||||||
this.#closeOnScroll.closeOnScrollExclude.set(
|
isOpenIcon = computed(() =>
|
||||||
this.optionsPanel()?.nativeElement,
|
this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown',
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
viewLabel = computed(() => {
|
||||||
|
if (!this.showSelectedValue()) {
|
||||||
open() {
|
return this.label() ?? this.value();
|
||||||
const selected = this.selectedOption();
|
}
|
||||||
if (selected) {
|
|
||||||
this.keyManger?.setActiveItem(selected);
|
const selectedOption = this.selectedOption();
|
||||||
} else {
|
|
||||||
this.keyManger?.setFirstItemActive();
|
if (!selectedOption || selectedOption.value() === null) {
|
||||||
}
|
return this.label() ?? this.value();
|
||||||
this.#dropdownService.open(this); // #5298 Fix
|
}
|
||||||
this.isOpen.set(true);
|
|
||||||
}
|
return selectedOption.getLabel();
|
||||||
|
});
|
||||||
close() {
|
|
||||||
this.isOpen.set(false);
|
constructor() {
|
||||||
this.#dropdownService.close(this); // #5298 Fix
|
effect(() => {
|
||||||
}
|
if (!this.init()) {
|
||||||
|
return;
|
||||||
focusout() {
|
}
|
||||||
// this.close();
|
this.keyManager?.destroy();
|
||||||
}
|
this.keyManager = new ActiveDescendantKeyManager<
|
||||||
|
DropdownOptionComponent<T>
|
||||||
ngAfterViewInit(): void {
|
>(this.options())
|
||||||
this.init.set(true);
|
.withWrap()
|
||||||
}
|
.skipPredicate((option) => option.disabled || option.isFiltered());
|
||||||
|
});
|
||||||
writeValue(obj: unknown): void {
|
|
||||||
this.value.set(obj as T);
|
// Configure CloseOnScrollDirective: activate when open, exclude options panel
|
||||||
}
|
effect(() => {
|
||||||
|
this.#closeOnScroll.closeOnScrollWhen.set(this.isOpen());
|
||||||
registerOnChange(fn: unknown): void {
|
this.#closeOnScroll.closeOnScrollExclude.set(
|
||||||
this.onChange = fn as (value: T) => void;
|
this.optionsPanel()?.nativeElement,
|
||||||
}
|
);
|
||||||
|
});
|
||||||
registerOnTouched(fn: unknown): void {
|
}
|
||||||
this.onTouched = fn as () => void;
|
|
||||||
}
|
open(): void {
|
||||||
|
this.#logger.debug('Opening dropdown');
|
||||||
setDisabledState?(isDisabled: boolean): void {
|
const selected = this.selectedOption();
|
||||||
this.disabled.set(isDisabled);
|
if (selected) {
|
||||||
}
|
this.keyManager?.setActiveItem(selected);
|
||||||
|
} else {
|
||||||
select(
|
this.keyManager?.setFirstItemActive();
|
||||||
option: DropdownOptionComponent<T>,
|
}
|
||||||
options: { emit: boolean } = { emit: true },
|
this.#dropdownService.open(this); // #5298 Fix
|
||||||
) {
|
this.isOpen.set(true);
|
||||||
this.value.set(option.value());
|
|
||||||
|
// Focus filter input if present (after overlay renders)
|
||||||
if (options.emit) {
|
const filterComp = this.filterComponent();
|
||||||
this.onChange?.(option.value());
|
if (filterComp) {
|
||||||
}
|
setTimeout(() => filterComp.focus());
|
||||||
this.onTouched?.();
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
close(): void {
|
||||||
|
this.#logger.debug('Closing dropdown');
|
||||||
|
this.filter.set(''); // Clear filter on close
|
||||||
|
this.isOpen.set(false);
|
||||||
|
this.#dropdownService.close(this); // #5298 Fix
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles space key press.
|
||||||
|
* Opens the dropdown if closed, or selects active item if open.
|
||||||
|
*/
|
||||||
|
onSpaceKey(event: Event): void {
|
||||||
|
event.preventDefault(); // Prevent page scroll
|
||||||
|
|
||||||
|
if (this.isOpen()) {
|
||||||
|
if (this.keyManager?.activeItem) {
|
||||||
|
this.select(this.keyManager.activeItem);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusout(): void {
|
||||||
|
// this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard events, forwarding to the keyManager for navigation.
|
||||||
|
* Called by host binding and can be called by child components (e.g., filter).
|
||||||
|
*/
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
this.keyManager?.onKeydown(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the currently active item from the keyManager.
|
||||||
|
* Called by child components (e.g., filter) on Enter key.
|
||||||
|
*/
|
||||||
|
selectActiveItem(): void {
|
||||||
|
if (this.keyManager?.activeItem) {
|
||||||
|
this.select(this.keyManager.activeItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.init.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue(obj: unknown): void {
|
||||||
|
this.value.set(obj as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: unknown): void {
|
||||||
|
this.onChange = fn as (value: T | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: unknown): void {
|
||||||
|
this.onTouched = fn as () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState?(isDisabled: boolean): void {
|
||||||
|
this.disabled.set(isDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
select(
|
||||||
|
option: DropdownOptionComponent<T> | null,
|
||||||
|
options: { emit: boolean } = { emit: true },
|
||||||
|
) {
|
||||||
|
let nextValue: T | null = null;
|
||||||
|
if (option === null) {
|
||||||
|
this.value.set(null);
|
||||||
|
} else {
|
||||||
|
nextValue = option.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.value.set(nextValue);
|
||||||
|
|
||||||
|
if (options.emit) {
|
||||||
|
this.onChange?.(nextValue);
|
||||||
|
}
|
||||||
|
this.onTouched?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@
|
|||||||
"@isa/checkout/feature/reward-shopping-cart": [
|
"@isa/checkout/feature/reward-shopping-cart": [
|
||||||
"libs/checkout/feature/reward-shopping-cart/src/index.ts"
|
"libs/checkout/feature/reward-shopping-cart/src/index.ts"
|
||||||
],
|
],
|
||||||
|
"@isa/checkout/feature/select-branch-dropdown": [
|
||||||
|
"libs/checkout/feature/select-branch-dropdown/src/index.ts"
|
||||||
|
],
|
||||||
"@isa/checkout/shared/product-info": [
|
"@isa/checkout/shared/product-info": [
|
||||||
"libs/checkout/shared/product-info/src/index.ts"
|
"libs/checkout/shared/product-info/src/index.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user