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';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { BranchDTO } from '@generated/swagger/checkout-api';
|
||||
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||
|
||||
/**
|
||||
* Service for opening and managing the Purchase Options Modal.
|
||||
@@ -39,6 +40,7 @@ export class PurchaseOptionsModalService {
|
||||
#uiModal = inject(UiModalService);
|
||||
#tabService = inject(TabService);
|
||||
#crmTabMetadataService = inject(CrmTabMetadataService);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
#customerFacade = inject(CustomerFacade);
|
||||
|
||||
/**
|
||||
@@ -74,7 +76,10 @@ export class PurchaseOptionsModalService {
|
||||
};
|
||||
|
||||
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>({
|
||||
content: PurchaseOptionsModalComponent,
|
||||
data: context,
|
||||
@@ -95,7 +100,10 @@ export class PurchaseOptionsModalService {
|
||||
return this.#customerFacade.fetchCustomer({ customerId });
|
||||
}
|
||||
|
||||
#getSelectedBranch(tabId: number): BranchDTO | undefined {
|
||||
#getSelectedBranch(
|
||||
tabId: number,
|
||||
useRedemptionPoints: boolean,
|
||||
): BranchDTO | undefined {
|
||||
const tab = untracked(() =>
|
||||
this.#tabService.entities().find((t) => t.id === tabId),
|
||||
);
|
||||
@@ -104,6 +112,10 @@ export class PurchaseOptionsModalService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (useRedemptionPoints) {
|
||||
return this.#checkoutMetadataService.getSelectedBranch(tabId);
|
||||
}
|
||||
|
||||
const legacyProcessData = tab?.metadata?.process_data;
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,51 +1,106 @@
|
||||
import {
|
||||
DropdownAppearance,
|
||||
DropdownButtonComponent,
|
||||
DropdownOptionComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { type Meta, type StoryObj, argsToTemplate, moduleMetadata } from '@storybook/angular';
|
||||
|
||||
type DropdownInputProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
appearance: DropdownAppearance;
|
||||
};
|
||||
|
||||
const meta: Meta<DropdownInputProps> = {
|
||||
title: 'ui/input-controls/Dropdown',
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
value: { control: 'text' },
|
||||
label: { control: 'text' },
|
||||
appearance: {
|
||||
control: 'select',
|
||||
options: Object.values(DropdownAppearance),
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<ui-dropdown ${argsToTemplate(args)}>
|
||||
<ui-dropdown-option value="">Select an option</ui-dropdown-option>
|
||||
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
|
||||
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
|
||||
<ui-dropdown-option value="3">Option 3</ui-dropdown-option>
|
||||
</ui-dropdown>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DropdownInputProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: undefined,
|
||||
label: 'Label',
|
||||
},
|
||||
};
|
||||
import {
|
||||
DropdownAppearance,
|
||||
DropdownButtonComponent,
|
||||
DropdownFilterComponent,
|
||||
DropdownOptionComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import {
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
argsToTemplate,
|
||||
moduleMetadata,
|
||||
} from '@storybook/angular';
|
||||
|
||||
type DropdownInputProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
appearance: DropdownAppearance;
|
||||
};
|
||||
|
||||
const meta: Meta<DropdownInputProps> = {
|
||||
title: 'ui/input-controls/Dropdown',
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
DropdownButtonComponent,
|
||||
DropdownFilterComponent,
|
||||
DropdownOptionComponent,
|
||||
],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
value: { control: 'text' },
|
||||
label: { control: 'text' },
|
||||
appearance: {
|
||||
control: 'select',
|
||||
options: Object.values(DropdownAppearance),
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<ui-dropdown ${argsToTemplate(args)}>
|
||||
<ui-dropdown-option value="">Select an option</ui-dropdown-option>
|
||||
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
|
||||
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
|
||||
<ui-dropdown-option value="3">Option 3</ui-dropdown-option>
|
||||
</ui-dropdown>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
> **Last Updated:** 2025-11-25
|
||||
> **Last Updated:** 2025-11-27
|
||||
> **Angular Version:** 20.3.6
|
||||
> **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.
|
||||
|
||||
@@ -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`
|
||||
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/`
|
||||
|
||||
### `@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`
|
||||
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
|
||||
- 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 { CheckoutMetadataService } from '../services/checkout-metadata.service';
|
||||
import { Branch } from '../schemas/branch.schema';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BranchFacade {
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
|
||||
getSelectedBranchId(tabId: number): number | undefined {
|
||||
return this.#checkoutMetadataService.getSelectedBranchId(tabId);
|
||||
getSelectedBranch(tabId: number): Branch | undefined {
|
||||
return this.#checkoutMetadataService.getSelectedBranch(tabId);
|
||||
}
|
||||
|
||||
setSelectedBranchId(tabId: number, branchId: number | undefined): void {
|
||||
this.#checkoutMetadataService.setSelectedBranchId(tabId, branchId);
|
||||
setSelectedBranch(tabId: number, branch: Branch | undefined): void {
|
||||
this.#checkoutMetadataService.setSelectedBranch(tabId, branch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { inject, Injectable, resource, signal } from '@angular/core';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { BranchService } from '../services';
|
||||
|
||||
@Injectable()
|
||||
export class BranchResource {
|
||||
#logger = logger({ service: 'BranchResource' });
|
||||
#branchService = inject(BranchService);
|
||||
|
||||
#params = signal<
|
||||
@@ -24,17 +26,20 @@ export class BranchResource {
|
||||
if ('branchId' in params && !params.branchId) {
|
||||
return null;
|
||||
}
|
||||
this.#logger.debug('Loading branch', () => params);
|
||||
const res = await this.#branchService.fetchBranches(abortSignal);
|
||||
return res.find((b) => {
|
||||
const branch = res.find((b) => {
|
||||
if ('branchId' in params) {
|
||||
return b.id === params.branchId;
|
||||
} else {
|
||||
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 './branches.resource';
|
||||
export * from './shopping-cart.resource';
|
||||
|
||||
@@ -4,27 +4,26 @@ import {
|
||||
CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY,
|
||||
CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY,
|
||||
CHECKOUT_SHOPPING_CART_ID_METADATA_KEY,
|
||||
COMPLETED_SHOPPING_CARTS_METADATA_KEY,
|
||||
SELECTED_BRANCH_METADATA_KEY,
|
||||
} from '../constants';
|
||||
import z from 'zod';
|
||||
import { ShoppingCart } from '../models';
|
||||
import { Branch, BranchSchema } from '../schemas/branch.schema';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CheckoutMetadataService {
|
||||
#tabService = inject(TabService);
|
||||
|
||||
setSelectedBranchId(tabId: number, branchId: number | undefined) {
|
||||
setSelectedBranch(tabId: number, branch: Branch | undefined) {
|
||||
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(
|
||||
tabId,
|
||||
SELECTED_BRANCH_METADATA_KEY,
|
||||
z.number().optional(),
|
||||
BranchSchema.optional(),
|
||||
this.#tabService.entityMap(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
|
||||
<reward-header></reward-header>
|
||||
<filter-controls-panel
|
||||
[switchFilters]="displayStockFilterSwitch()"
|
||||
(triggerSearch)="search($event)"
|
||||
></filter-controls-panel>
|
||||
<reward-list
|
||||
[searchTrigger]="searchTrigger()"
|
||||
(searchTriggerChange)="searchTrigger.set($event)"
|
||||
></reward-list>
|
||||
<reward-action></reward-action>
|
||||
@let tId = tabId();
|
||||
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
|
||||
<reward-header></reward-header>
|
||||
<filter-controls-panel
|
||||
[forceMobileLayout]="isCallCenter"
|
||||
[switchFilters]="displayStockFilterSwitch()"
|
||||
(triggerSearch)="search($event)"
|
||||
>
|
||||
@if (isCallCenter && tId) {
|
||||
<checkout-selected-branch-dropdown [tabId]="tId" />
|
||||
}
|
||||
</filter-controls-panel>
|
||||
<reward-list
|
||||
[searchTrigger]="searchTrigger()"
|
||||
(searchTriggerChange)="searchTrigger.set($event)"
|
||||
></reward-list>
|
||||
<reward-action></reward-action>
|
||||
|
||||
@@ -14,14 +14,18 @@ import {
|
||||
SearchTrigger,
|
||||
FilterService,
|
||||
FilterInput,
|
||||
SwitchMenuButtonComponent,
|
||||
} from '@isa/shared/filter';
|
||||
import { RewardHeaderComponent } from './reward-header/reward-header.component';
|
||||
import { RewardListComponent } from './reward-list/reward-list.component';
|
||||
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
|
||||
import { RewardActionComponent } from './reward-action/reward-action.component';
|
||||
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
|
||||
import { SelectedBranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
|
||||
import { SelectedCustomerResource } from '@isa/crm/data-access';
|
||||
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.
|
||||
@@ -50,6 +54,7 @@ function querySettingsFactory() {
|
||||
RewardHeaderComponent,
|
||||
RewardListComponent,
|
||||
RewardActionComponent,
|
||||
SelectedBranchDropdownComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
@@ -57,6 +62,10 @@ function querySettingsFactory() {
|
||||
},
|
||||
})
|
||||
export class RewardCatalogComponent {
|
||||
readonly tabId = injectTabId();
|
||||
|
||||
readonly isCallCenter = inject(RoleService).hasRole(Role.CallCenter);
|
||||
|
||||
restoreScrollPosition = injectRestoreScrollPosition();
|
||||
|
||||
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
|
||||
@@ -64,6 +73,9 @@ export class RewardCatalogComponent {
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
displayStockFilterSwitch = computed(() => {
|
||||
if (this.isCallCenter) {
|
||||
return [];
|
||||
}
|
||||
const stockInput = this.#filterService
|
||||
.inputs()
|
||||
?.filter((input) => input.target === 'filter')
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
@let i = item();
|
||||
<ui-client-row data-what="reward-list-item" [attr.data-which]="i.id">
|
||||
<ui-client-row-content
|
||||
class="flex-grow"
|
||||
[class.row-start-1]="!desktopBreakpoint()"
|
||||
>
|
||||
<checkout-product-info-redemption
|
||||
[item]="i"
|
||||
[orientation]="productInfoOrientation()"
|
||||
></checkout-product-info-redemption>
|
||||
</ui-client-row-content>
|
||||
<ui-item-row-data
|
||||
class="flex-grow"
|
||||
[class.stock-row-tablet]="!desktopBreakpoint()"
|
||||
>
|
||||
<checkout-stock-info [item]="i"></checkout-stock-info>
|
||||
</ui-item-row-data>
|
||||
|
||||
<ui-item-row-data
|
||||
class="justify-center"
|
||||
[class.select-row-tablet]="!desktopBreakpoint()"
|
||||
>
|
||||
<reward-list-item-select
|
||||
class="self-end"
|
||||
[item]="i"
|
||||
></reward-list-item-select>
|
||||
</ui-item-row-data>
|
||||
</ui-client-row>
|
||||
@let i = item();
|
||||
<ui-client-row data-what="reward-list-item" [attr.data-which]="i.id">
|
||||
<ui-client-row-content
|
||||
class="flex-grow"
|
||||
[class.row-start-1]="!desktopBreakpoint()"
|
||||
>
|
||||
<checkout-product-info-redemption
|
||||
[item]="i"
|
||||
[orientation]="productInfoOrientation()"
|
||||
></checkout-product-info-redemption>
|
||||
</ui-client-row-content>
|
||||
<ui-item-row-data
|
||||
class="flex-grow"
|
||||
[class.stock-row-tablet]="!desktopBreakpoint()"
|
||||
>
|
||||
<checkout-stock-info
|
||||
[item]="i"
|
||||
[branchId]="selectedBranchId()"
|
||||
></checkout-stock-info>
|
||||
</ui-item-row-data>
|
||||
|
||||
<ui-item-row-data
|
||||
class="justify-center"
|
||||
[class.select-row-tablet]="!desktopBreakpoint()"
|
||||
>
|
||||
<reward-list-item-select
|
||||
class="self-end"
|
||||
[item]="i"
|
||||
></reward-list-item-select>
|
||||
</ui-item-row-data>
|
||||
</ui-client-row>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { Item } from '@isa/catalogue/data-access';
|
||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||
import { ProductInfoRedemptionComponent } 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 { injectTabId } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'reward-list-item',
|
||||
@@ -25,6 +29,18 @@ import { RewardListItemSelectComponent } from './reward-list-item-select/reward-
|
||||
],
|
||||
})
|
||||
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>();
|
||||
|
||||
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,
|
||||
} from '@angular/core';
|
||||
import { StockInfoResource } from '@isa/remission/data-access';
|
||||
import { Branch } from '@isa/checkout/data-access';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaFiliale } from '@isa/icons';
|
||||
import { Item } from '@isa/catalogue/data-access';
|
||||
@@ -31,10 +32,17 @@ export class StockInfoComponent {
|
||||
|
||||
item = input.required<StockInfoItem>();
|
||||
|
||||
branchId = input<number | null>(null);
|
||||
|
||||
itemId = computed(() => this.item().id);
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,11 @@ import { RemissionStockService } from '../services';
|
||||
import { FetchStockInStock } from '../schemas';
|
||||
import { StockInfo } from '../models';
|
||||
|
||||
export type StockInfoResourceParams = { itemId: number } & (
|
||||
| { stockId?: number }
|
||||
| { branchId: number }
|
||||
);
|
||||
|
||||
/**
|
||||
* Smart batching resource for stock information.
|
||||
* Collects item params from multiple components, waits for a batching window,
|
||||
@@ -19,11 +24,12 @@ import { StockInfo } from '../models';
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StockInfoResource extends BatchingResource<
|
||||
{ itemId: number; stockId?: number },
|
||||
StockInfoResourceParams,
|
||||
FetchStockInStock,
|
||||
StockInfo
|
||||
> {
|
||||
#stockService = inject(RemissionStockService);
|
||||
#currentBatchBranchId?: number;
|
||||
|
||||
constructor() {
|
||||
super(250); // batchWindowMs
|
||||
@@ -43,27 +49,51 @@ export class StockInfoResource extends BatchingResource<
|
||||
* Build API request params from list of item params.
|
||||
*/
|
||||
protected buildParams(
|
||||
paramsList: { itemId: number; stockId?: number }[],
|
||||
paramsList: { itemId: number; stockId?: number; branchId?: number }[],
|
||||
): 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 {
|
||||
itemIds: paramsList.map((p) => p.itemId),
|
||||
stockId: paramsList[0]?.stockId,
|
||||
stockId: first?.stockId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract params from result for cache matching.
|
||||
* Uses tracked branchId since StockInfo doesn't contain it.
|
||||
*/
|
||||
protected getKeyFromResult(
|
||||
stock: StockInfo,
|
||||
): { itemId: number; stockId?: number } | undefined {
|
||||
return stock.itemId !== undefined ? { itemId: stock.itemId } : undefined;
|
||||
): { itemId: number; stockId?: number; branchId?: number } | undefined {
|
||||
if (stock.itemId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
itemId: stock.itemId,
|
||||
branchId: this.#currentBatchBranchId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
@@ -76,7 +106,8 @@ export class StockInfoResource extends BatchingResource<
|
||||
): { itemId: number; stockId?: number }[] {
|
||||
return params.itemIds.map((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';
|
||||
|
||||
export const FetchStockInStockSchema = z.object({
|
||||
export const FetchStockInStockWithStockIdSchema = z.object({
|
||||
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>;
|
||||
|
||||
@@ -157,8 +157,17 @@ export class RemissionStockService {
|
||||
|
||||
let assignedStockId: number;
|
||||
|
||||
if (parsed.stockId) {
|
||||
if ('stockId' in parsed && 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 {
|
||||
assignedStockId = await this.fetchAssignedStock(abortSignal).then(
|
||||
(s) => s.id,
|
||||
@@ -166,7 +175,7 @@ export class RemissionStockService {
|
||||
}
|
||||
|
||||
this.#logger.info('Fetching stock info from API', () => ({
|
||||
stockId: parsed.stockId,
|
||||
stockId: assignedStockId,
|
||||
itemCount: parsed.itemIds.length,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
<div
|
||||
class="w-full flex flex-row justify-between items-start"
|
||||
[class.empty-filter-input]="!hasFilter() && !hasInput()"
|
||||
>
|
||||
@if (hasInput()) {
|
||||
<filter-search-bar-input
|
||||
class="flex flex-row gap-4 h-12"
|
||||
[appearance]="'results'"
|
||||
[inputKey]="inputKey()"
|
||||
(triggerSearch)="triggerSearch.emit($event)"
|
||||
data-what="search-input"
|
||||
></filter-search-bar-input>
|
||||
}
|
||||
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
@for (switchFilter of switchFilters(); track switchFilter.filter.key) {
|
||||
<filter-switch-menu-button
|
||||
[filterInput]="switchFilter.filter"
|
||||
[icon]="switchFilter.icon"
|
||||
(toggled)="triggerSearch.emit('filter')"
|
||||
></filter-switch-menu-button>
|
||||
}
|
||||
|
||||
@if (hasFilter()) {
|
||||
<filter-filter-menu-button
|
||||
(applied)="triggerSearch.emit('filter')"
|
||||
[rollbackOnClose]="true"
|
||||
></filter-filter-menu-button>
|
||||
}
|
||||
|
||||
@if (mobileBreakpoint()) {
|
||||
<ui-icon-button
|
||||
type="button"
|
||||
(click)="showOrderByToolbarMobile.set(!showOrderByToolbarMobile())"
|
||||
[class.active]="showOrderByToolbarMobile()"
|
||||
data-what="sort-button-mobile"
|
||||
name="isaActionSort"
|
||||
></ui-icon-button>
|
||||
} @else {
|
||||
<filter-order-by-toolbar
|
||||
[class.empty-filter-input-width]="!hasFilter() && !hasInput()"
|
||||
(toggled)="triggerSearch.emit('order-by')"
|
||||
data-what="sort-toolbar"
|
||||
></filter-order-by-toolbar>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (mobileBreakpoint() && showOrderByToolbarMobile()) {
|
||||
<filter-order-by-toolbar
|
||||
class="w-full"
|
||||
(toggled)="triggerSearch.emit('order-by')"
|
||||
data-what="sort-toolbar-mobile"
|
||||
></filter-order-by-toolbar>
|
||||
}
|
||||
<div
|
||||
class="w-full flex flex-row justify-between items-start"
|
||||
[class.empty-filter-input]="!hasFilter() && !hasInput()"
|
||||
>
|
||||
@if (hasInput()) {
|
||||
<filter-search-bar-input
|
||||
class="flex flex-row gap-4 h-12"
|
||||
[appearance]="'results'"
|
||||
[inputKey]="inputKey()"
|
||||
(triggerSearch)="triggerSearch.emit($event)"
|
||||
data-what="search-input"
|
||||
></filter-search-bar-input>
|
||||
}
|
||||
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
<ng-content></ng-content>
|
||||
|
||||
@for (switchFilter of switchFilters(); track switchFilter.filter.key) {
|
||||
<filter-switch-menu-button
|
||||
[filterInput]="switchFilter.filter"
|
||||
[icon]="switchFilter.icon"
|
||||
(toggled)="triggerSearch.emit('filter')"
|
||||
></filter-switch-menu-button>
|
||||
}
|
||||
|
||||
@if (hasFilter()) {
|
||||
<filter-filter-menu-button
|
||||
(applied)="triggerSearch.emit('filter')"
|
||||
[rollbackOnClose]="true"
|
||||
></filter-filter-menu-button>
|
||||
}
|
||||
|
||||
@if (mobileLayout()) {
|
||||
<ui-icon-button
|
||||
type="button"
|
||||
(click)="showOrderByToolbarMobile.set(!showOrderByToolbarMobile())"
|
||||
[class.active]="showOrderByToolbarMobile()"
|
||||
data-what="sort-button-mobile"
|
||||
name="isaActionSort"
|
||||
></ui-icon-button>
|
||||
} @else {
|
||||
<filter-order-by-toolbar
|
||||
[class.empty-filter-input-width]="!hasFilter() && !hasInput()"
|
||||
(toggled)="triggerSearch.emit('order-by')"
|
||||
data-what="sort-toolbar"
|
||||
></filter-order-by-toolbar>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (mobileLayout() && showOrderByToolbarMobile()) {
|
||||
<filter-order-by-toolbar
|
||||
class="w-full"
|
||||
(toggled)="triggerSearch.emit('order-by')"
|
||||
data-what="sort-toolbar-mobile"
|
||||
></filter-order-by-toolbar>
|
||||
}
|
||||
|
||||
@@ -1,135 +1,141 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { FilterMenuButtonComponent } from '../menus/filter-menu';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaActionFilter, isaActionSort } from '@isa/icons';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { OrderByToolbarComponent } from '../order-by';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { InputType, SearchTrigger } from '../types';
|
||||
import { FilterService, TextFilterInput, FilterInput } from '../core';
|
||||
import { SearchBarInputComponent } from '../inputs';
|
||||
import { SwitchMenuButtonComponent } from '../menus/switch-menu';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* It adapts its layout based on screen size, showing/hiding controls appropriately for mobile and desktop views.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <filter-controls-panel
|
||||
* (triggerSearch)="handleSearch($event)">
|
||||
* </filter-controls-panel>
|
||||
* ```
|
||||
*
|
||||
* Features:
|
||||
* - Responsive design that adapts to mobile/desktop layouts
|
||||
* - Integrated search bar with scanner support
|
||||
* - Filter menu with rollback functionality
|
||||
* - Sortable order-by controls
|
||||
* - Emits typed search trigger events
|
||||
*/
|
||||
@Component({
|
||||
selector: 'filter-controls-panel',
|
||||
templateUrl: './controls-panel.component.html',
|
||||
styleUrls: ['./controls-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: true,
|
||||
imports: [
|
||||
SearchBarInputComponent,
|
||||
FilterMenuButtonComponent,
|
||||
IconButtonComponent,
|
||||
OrderByToolbarComponent,
|
||||
SwitchMenuButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]': "['filter-controls-panel']",
|
||||
},
|
||||
providers: [provideIcons({ isaActionSort, isaActionFilter })],
|
||||
})
|
||||
export class FilterControlsPanelComponent {
|
||||
/**
|
||||
* Service for managing filter state and operations.
|
||||
*/
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
/**
|
||||
* The unique key identifier for this input in the filter system.
|
||||
* @default 'qs'
|
||||
*/
|
||||
inputKey = signal('qs');
|
||||
|
||||
/**
|
||||
* Optional array of switch filter configurations to display as toggle switches.
|
||||
* Each item should be a filter input with an associated icon.
|
||||
* Switch filters are rendered to the left of the filter menu button.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* switchFilters = [
|
||||
* {
|
||||
* filter: availabilityFilter,
|
||||
* icon: 'isaActionCheck'
|
||||
* }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
switchFilters = input<Array<{ filter: FilterInput; icon: string }>>([]);
|
||||
|
||||
/**
|
||||
* Output event that emits when any search action is triggered.
|
||||
* Provides the specific SearchTrigger type to indicate how the search was initiated:
|
||||
* - 'input': Text input or search button
|
||||
* - 'filter': Filter menu changes
|
||||
* - 'order-by': Sort order changes
|
||||
* - 'scan': Barcode scan
|
||||
*/
|
||||
triggerSearch = output<SearchTrigger>();
|
||||
|
||||
/**
|
||||
* Signal tracking whether the viewport is at tablet size or above.
|
||||
* Used to determine responsive layout behavior for mobile vs desktop.
|
||||
*/
|
||||
mobileBreakpoint = breakpoint([Breakpoint.Tablet, Breakpoint.Desktop]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Linked to mobileBreakpoint to automatically adjust when screen size changes.
|
||||
*/
|
||||
showOrderByToolbarMobile = linkedSignal(() => !this.mobileBreakpoint());
|
||||
|
||||
/**
|
||||
* Computed signal that determines if the search input is present in the filter inputs.
|
||||
* This checks if there is a TextFilterInput with the specified inputKey.
|
||||
* Used to conditionally render the search input in the template.
|
||||
*/
|
||||
hasInput = computed(() => {
|
||||
const inputs = this.#filterService.inputs();
|
||||
const input = inputs.find(
|
||||
(input) => input.key === this.inputKey() && input.type === InputType.Text,
|
||||
) as TextFilterInput;
|
||||
return !!input;
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { FilterMenuButtonComponent } from '../menus/filter-menu';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaActionFilter, isaActionSort } from '@isa/icons';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { OrderByToolbarComponent } from '../order-by';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { InputType, SearchTrigger } from '../types';
|
||||
import { FilterService, TextFilterInput, FilterInput } from '../core';
|
||||
import { SearchBarInputComponent } from '../inputs';
|
||||
import { SwitchMenuButtonComponent } from '../menus/switch-menu';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* It adapts its layout based on screen size, showing/hiding controls appropriately for mobile and desktop views.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <filter-controls-panel
|
||||
* (triggerSearch)="handleSearch($event)">
|
||||
* </filter-controls-panel>
|
||||
* ```
|
||||
*
|
||||
* Features:
|
||||
* - Responsive design that adapts to mobile/desktop layouts
|
||||
* - Integrated search bar with scanner support
|
||||
* - Filter menu with rollback functionality
|
||||
* - Sortable order-by controls
|
||||
* - Emits typed search trigger events
|
||||
*/
|
||||
@Component({
|
||||
selector: 'filter-controls-panel',
|
||||
templateUrl: './controls-panel.component.html',
|
||||
styleUrls: ['./controls-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: true,
|
||||
imports: [
|
||||
SearchBarInputComponent,
|
||||
FilterMenuButtonComponent,
|
||||
IconButtonComponent,
|
||||
OrderByToolbarComponent,
|
||||
SwitchMenuButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]': "['filter-controls-panel']",
|
||||
},
|
||||
providers: [provideIcons({ isaActionSort, isaActionFilter })],
|
||||
})
|
||||
export class FilterControlsPanelComponent {
|
||||
/**
|
||||
* Service for managing filter state and operations.
|
||||
*/
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
/**
|
||||
* The unique key identifier for this input in the filter system.
|
||||
* @default 'qs'
|
||||
*/
|
||||
inputKey = signal('qs');
|
||||
|
||||
/**
|
||||
* Optional array of switch filter configurations to display as toggle switches.
|
||||
* Each item should be a filter input with an associated icon.
|
||||
* Switch filters are rendered to the left of the filter menu button.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* switchFilters = [
|
||||
* {
|
||||
* filter: availabilityFilter,
|
||||
* icon: 'isaActionCheck'
|
||||
* }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
switchFilters = input<Array<{ filter: FilterInput; icon: string }>>([]);
|
||||
|
||||
/**
|
||||
* Output event that emits when any search action is triggered.
|
||||
* Provides the specific SearchTrigger type to indicate how the search was initiated:
|
||||
* - 'input': Text input or search button
|
||||
* - 'filter': Filter menu changes
|
||||
* - 'order-by': Sort order changes
|
||||
* - 'scan': Barcode scan
|
||||
*/
|
||||
triggerSearch = output<SearchTrigger>();
|
||||
|
||||
/**
|
||||
* Signal tracking whether the viewport is at tablet size or above.
|
||||
* Used to determine responsive layout behavior for mobile vs desktop.
|
||||
*/
|
||||
mobileBreakpoint = breakpoint([Breakpoint.Tablet, Breakpoint.Desktop]);
|
||||
|
||||
forceMobileLayout = input(false);
|
||||
|
||||
mobileLayout = computed(
|
||||
() => this.forceMobileLayout() || this.mobileBreakpoint(),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Linked to mobileBreakpoint to automatically adjust when screen size changes.
|
||||
*/
|
||||
showOrderByToolbarMobile = linkedSignal(() => !this.mobileLayout());
|
||||
|
||||
/**
|
||||
* Computed signal that determines if the search input is present in the filter inputs.
|
||||
* This checks if there is a TextFilterInput with the specified inputKey.
|
||||
* Used to conditionally render the search input in the template.
|
||||
*/
|
||||
hasInput = computed(() => {
|
||||
const inputs = this.#filterService.inputs();
|
||||
const input = inputs.find(
|
||||
(input) => input.key === this.inputKey() && input.type === InputType.Text,
|
||||
) as TextFilterInput;
|
||||
return !!input;
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
CheckboxComponent,
|
||||
DropdownButtonComponent,
|
||||
DropdownFilterComponent,
|
||||
DropdownOptionComponent,
|
||||
TextFieldComponent,
|
||||
InputControlDirective,
|
||||
@@ -52,6 +53,7 @@ import { ReactiveFormsModule } from '@angular/forms';
|
||||
ReactiveFormsModule,
|
||||
CheckboxComponent,
|
||||
DropdownButtonComponent,
|
||||
DropdownFilterComponent,
|
||||
DropdownOptionComponent,
|
||||
TextFieldComponent,
|
||||
InputControlDirective,
|
||||
@@ -92,6 +94,17 @@ export class MyFormComponent {}
|
||||
</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
|
||||
|
||||
### Component Categories
|
||||
@@ -119,6 +132,7 @@ The library provides three main categories of form controls:
|
||||
- **ChecklistValueDirective** - Value binding for checklist items
|
||||
- **ChipOptionComponent** - Individual chip option
|
||||
- **DropdownOptionComponent** - Individual dropdown option
|
||||
- **DropdownFilterComponent** - Filter input for dropdown options
|
||||
- **ListboxItemDirective** - Individual listbox item
|
||||
|
||||
### 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
|
||||
- `tabIndex: number` - Tab index for keyboard navigation. Default: 0
|
||||
- `id: string` - Optional element ID
|
||||
- `equals: (a: T | null, b: T | null) => boolean` - Custom equality function for comparing option values. Default: `lodash.isEqual`
|
||||
|
||||
**Outputs:**
|
||||
- `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
|
||||
- `Enter` - Select highlighted option and close
|
||||
- `Escape` - Close dropdown
|
||||
- `Space` - Toggle dropdown open/close (when focused)
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
@@ -236,6 +252,93 @@ selectedProduct: Product;
|
||||
</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>
|
||||
|
||||
@@ -4,6 +4,9 @@ export * from './lib/checkbox/checklist-value.directive';
|
||||
export * from './lib/checkbox/checklist.component';
|
||||
export * from './lib/core/input-control.directive';
|
||||
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/listbox/listbox-item.directive';
|
||||
export * from './lib/listbox/listbox.directive';
|
||||
|
||||
@@ -1,118 +1,117 @@
|
||||
.ui-dropdown {
|
||||
display: inline-flex;
|
||||
height: 3rem;
|
||||
padding: 0rem 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 3.125rem;
|
||||
justify-content: space-between;
|
||||
|
||||
ng-icon {
|
||||
@apply min-w-5 size-5 mt-[0.125rem];
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
@apply text-isa-white bg-isa-neutral-400 border-isa-neutral-400 cursor-default;
|
||||
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-400 border-isa-neutral-400;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@apply border-isa-neutral-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dropdown__accent-outline {
|
||||
@apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue;
|
||||
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-100 border-isa-secondary-700;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@apply border-isa-accent-blue;
|
||||
}
|
||||
|
||||
&.open {
|
||||
@apply border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dropdown__grey {
|
||||
@apply text-isa-neutral-600 isa-text-body-2-bold bg-isa-neutral-400 border border-solid border-isa-neutral-400;
|
||||
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-500;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.has-value {
|
||||
@apply border-isa-accent-blue text-isa-accent-blue;
|
||||
}
|
||||
|
||||
&.open {
|
||||
@apply border-isa-neutral-900 text-isa-neutral-900 bg-isa-white;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dropdown__text {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.ui-dropdown__options {
|
||||
// Fixed typo from ui-dorpdown__options
|
||||
display: inline-flex;
|
||||
padding: 0.25rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 1.25rem;
|
||||
background: var(--Neutral-White, #fff);
|
||||
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-height: 20rem;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.ui-dropdown-option {
|
||||
display: flex;
|
||||
width: 10rem;
|
||||
height: 3rem;
|
||||
min-height: 3rem;
|
||||
padding: 0rem 1.5rem;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: 1rem;
|
||||
word-wrap: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
@apply isa-text-body-2-bold;
|
||||
|
||||
&.active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-200;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
@apply text-isa-accent-blue;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
@apply text-isa-neutral-400;
|
||||
|
||||
&.active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
@apply bg-isa-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ui-dropdown {
|
||||
display: inline-flex;
|
||||
height: 3rem;
|
||||
padding: 0rem 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 3.125rem;
|
||||
justify-content: space-between;
|
||||
|
||||
ng-icon {
|
||||
@apply min-w-5 size-5 mt-[0.125rem];
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
@apply text-isa-white bg-isa-neutral-400 border-isa-neutral-400 cursor-default;
|
||||
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-400 border-isa-neutral-400;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@apply border-isa-neutral-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dropdown__accent-outline {
|
||||
@apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue;
|
||||
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-100 border-isa-secondary-700;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@apply border-isa-accent-blue;
|
||||
}
|
||||
|
||||
&.open {
|
||||
@apply border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dropdown__grey {
|
||||
@apply text-isa-neutral-600 isa-text-body-2-bold bg-isa-neutral-400 border border-solid border-isa-neutral-400;
|
||||
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-500;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.has-value {
|
||||
@apply border-isa-accent-blue text-isa-accent-blue;
|
||||
}
|
||||
|
||||
&.open {
|
||||
@apply border-isa-neutral-900 text-isa-neutral-900 bg-isa-white;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dropdown__text {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.ui-dropdown__options {
|
||||
@apply inline-flex flex-col items-start px-1 w-full max-h-80 overflow-hidden overflow-y-auto;
|
||||
@apply rounded-[1.25rem] bg-isa-white shadow-[0px_0px_16px_0px_rgba(0,0,0,0.15)];
|
||||
|
||||
.ui-dropdown__filter {
|
||||
@apply sticky top-0 px-4 pt-6 pb-5 bg-isa-white list-none w-full;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ui-dropdown-option {
|
||||
display: flex;
|
||||
width: 10rem;
|
||||
height: 3rem;
|
||||
min-height: 3rem;
|
||||
padding: 0rem 1.5rem;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: 1rem;
|
||||
word-wrap: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
@apply isa-text-body-2-bold;
|
||||
|
||||
&.active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-200;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
@apply text-isa-accent-blue;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
@apply text-isa-neutral-400;
|
||||
|
||||
&.active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
@apply bg-isa-white;
|
||||
}
|
||||
}
|
||||
|
||||
&.filtered {
|
||||
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>
|
||||
<ng-icon [name]="isOpenIcon()"></ng-icon>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="cdkOverlayOrigin"
|
||||
[cdkConnectedOverlayOpen]="isOpen()"
|
||||
[cdkConnectedOverlayOffsetY]="12"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
[cdkConnectedOverlayDisableClose]="false"
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
||||
[cdkConnectedOverlayLockPosition]="true"
|
||||
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
||||
(backdropClick)="close()"
|
||||
(detach)="isOpen.set(false)"
|
||||
>
|
||||
<ul #optionsPanel [class]="['ui-dropdown__options']" role="listbox">
|
||||
<ng-content></ng-content>
|
||||
</ul>
|
||||
</ng-template>
|
||||
<span [class]="['ui-dropdown__text']">{{ viewLabel() }}</span>
|
||||
<ng-icon [name]="isOpenIcon()"></ng-icon>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="cdkOverlayOrigin"
|
||||
[cdkConnectedOverlayOpen]="isOpen()"
|
||||
[cdkConnectedOverlayPositions]="overlayPositions"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
[cdkConnectedOverlayDisableClose]="false"
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
||||
[cdkConnectedOverlayLockPosition]="true"
|
||||
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
||||
(backdropClick)="close()"
|
||||
(detach)="isOpen.set(false)"
|
||||
>
|
||||
<ul #optionsPanel [class]="['ui-dropdown__options']" role="listbox">
|
||||
<ng-content select="ui-dropdown-filter"></ng-content>
|
||||
<ng-content></ng-content>
|
||||
</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 {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
contentChildren,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
input,
|
||||
model,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
||||
import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
|
||||
import {
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
ScrollStrategyOptions,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { isEqual } from 'lodash';
|
||||
import { DropdownAppearance } from './dropdown.types';
|
||||
import { DropdownService } from './dropdown.service';
|
||||
import { CloseOnScrollDirective } from '@isa/ui/layout';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-dropdown-option',
|
||||
template: '<ng-content></ng-content>',
|
||||
host: {
|
||||
'[class]':
|
||||
'["ui-dropdown-option", activeClass(), selectedClass(), disabledClass()]',
|
||||
'role': 'option',
|
||||
'[attr.aria-selected]': 'selected()',
|
||||
'[attr.tabindex]': '-1',
|
||||
'(click)': 'select()',
|
||||
},
|
||||
})
|
||||
export class DropdownOptionComponent<T> implements Highlightable {
|
||||
private host = inject(DropdownButtonComponent<T>);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
active = signal(false);
|
||||
|
||||
readonly _disabled = signal<boolean>(false);
|
||||
|
||||
@Input()
|
||||
get disabled(): boolean {
|
||||
return this._disabled();
|
||||
}
|
||||
|
||||
set disabled(value: boolean) {
|
||||
this._disabled.set(value);
|
||||
}
|
||||
|
||||
disabledClass = computed(() => (this.disabled ? 'disabled' : ''));
|
||||
|
||||
activeClass = computed(() => (this.active() ? 'active' : ''));
|
||||
|
||||
setActiveStyles(): void {
|
||||
this.active.set(true);
|
||||
}
|
||||
|
||||
setInactiveStyles(): void {
|
||||
this.active.set(false);
|
||||
}
|
||||
|
||||
getLabel(): string {
|
||||
return this.elementRef.nativeElement.textContent.trim();
|
||||
}
|
||||
|
||||
selected = computed(() => {
|
||||
const hostValue = this.host.value();
|
||||
const value = this.value();
|
||||
return hostValue === value || isEqual(hostValue, value);
|
||||
});
|
||||
|
||||
selectedClass = computed(() => (this.selected() ? 'selected' : ''));
|
||||
|
||||
value = input.required<T>();
|
||||
|
||||
select() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
this.host.select(this);
|
||||
this.host.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ui-dropdown',
|
||||
templateUrl: './dropdown.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
hostDirectives: [
|
||||
CdkOverlayOrigin,
|
||||
{
|
||||
directive: CloseOnScrollDirective,
|
||||
outputs: ['closeOnScroll'],
|
||||
},
|
||||
],
|
||||
imports: [NgIconComponent, CdkConnectedOverlay],
|
||||
providers: [
|
||||
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: DropdownButtonComponent,
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
'["ui-dropdown", appearanceClass(), isOpenClass(), disabledClass(), valueClass()]',
|
||||
'role': 'combobox',
|
||||
'aria-haspopup': 'listbox',
|
||||
'[attr.id]': 'id()',
|
||||
'[attr.tabindex]': 'disabled() ? -1 : tabIndex()',
|
||||
'aria-expanded': 'isOpen()',
|
||||
'(keydown)': 'keyManger?.onKeydown($event)',
|
||||
'(keydown.enter)': 'select(keyManger.activeItem); close()',
|
||||
'(keydown.escape)': 'close()',
|
||||
'(click)':
|
||||
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
|
||||
'(closeOnScroll)': 'close()',
|
||||
},
|
||||
})
|
||||
export class DropdownButtonComponent<T>
|
||||
implements ControlValueAccessor, AfterViewInit
|
||||
{
|
||||
#dropdownService = inject(DropdownService);
|
||||
#scrollStrategy = inject(ScrollStrategyOptions);
|
||||
#closeOnScroll = inject(CloseOnScrollDirective, { self: true });
|
||||
|
||||
readonly init = signal(false);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
/** Reference to the options panel for scroll exclusion */
|
||||
optionsPanel = viewChild<ElementRef<HTMLElement>>('optionsPanel');
|
||||
|
||||
get overlayMinWidth() {
|
||||
return this.elementRef.nativeElement.offsetWidth;
|
||||
}
|
||||
|
||||
get blockScrollStrategy() {
|
||||
return this.#scrollStrategy.block();
|
||||
}
|
||||
|
||||
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
||||
|
||||
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
|
||||
|
||||
disabledClass = computed(() => (this.disabled() ? 'disabled' : ''));
|
||||
|
||||
valueClass = computed(() => (this.value() ? 'has-value' : ''));
|
||||
|
||||
id = input<string>();
|
||||
|
||||
value = model<T>();
|
||||
|
||||
tabIndex = input<number>(0);
|
||||
|
||||
label = input<string>();
|
||||
|
||||
disabled = model<boolean>(false);
|
||||
|
||||
showSelectedValue = input<boolean>(true);
|
||||
|
||||
options = contentChildren(DropdownOptionComponent);
|
||||
|
||||
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
|
||||
|
||||
selectedOption = computed(() => {
|
||||
const options = this.options();
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return options.find((option) => option.value() === this.value());
|
||||
});
|
||||
|
||||
private keyManger?: ActiveDescendantKeyManager<DropdownOptionComponent<T>>;
|
||||
|
||||
onChange?: (value: T) => void;
|
||||
|
||||
onTouched?: () => void;
|
||||
|
||||
isOpen = signal(false);
|
||||
|
||||
isOpenClass = computed(() => (this.isOpen() ? 'open' : ''));
|
||||
|
||||
isOpenIcon = computed(() =>
|
||||
this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown',
|
||||
);
|
||||
|
||||
viewLabel = computed(() => {
|
||||
if (!this.showSelectedValue()) {
|
||||
return this.label() ?? this.value();
|
||||
}
|
||||
|
||||
const selectedOption = this.selectedOption();
|
||||
|
||||
if (!selectedOption) {
|
||||
return this.label() ?? this.value();
|
||||
}
|
||||
|
||||
return this.label() ?? selectedOption.getLabel();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.init()) {
|
||||
return;
|
||||
}
|
||||
this.keyManger?.destroy();
|
||||
this.keyManger = new ActiveDescendantKeyManager<
|
||||
DropdownOptionComponent<T>
|
||||
>(this.options())
|
||||
.withWrap()
|
||||
.skipPredicate((option) => option.disabled);
|
||||
});
|
||||
|
||||
// Configure CloseOnScrollDirective: activate when open, exclude options panel
|
||||
effect(() => {
|
||||
this.#closeOnScroll.closeOnScrollWhen.set(this.isOpen());
|
||||
this.#closeOnScroll.closeOnScrollExclude.set(
|
||||
this.optionsPanel()?.nativeElement,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
const selected = this.selectedOption();
|
||||
if (selected) {
|
||||
this.keyManger?.setActiveItem(selected);
|
||||
} else {
|
||||
this.keyManger?.setFirstItemActive();
|
||||
}
|
||||
this.#dropdownService.open(this); // #5298 Fix
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen.set(false);
|
||||
this.#dropdownService.close(this); // #5298 Fix
|
||||
}
|
||||
|
||||
focusout() {
|
||||
// this.close();
|
||||
}
|
||||
|
||||
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) => void;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: unknown): void {
|
||||
this.onTouched = fn as () => void;
|
||||
}
|
||||
|
||||
setDisabledState?(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
|
||||
select(
|
||||
option: DropdownOptionComponent<T>,
|
||||
options: { emit: boolean } = { emit: true },
|
||||
) {
|
||||
this.value.set(option.value());
|
||||
|
||||
if (options.emit) {
|
||||
this.onChange?.(option.value());
|
||||
}
|
||||
this.onTouched?.();
|
||||
}
|
||||
}
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
contentChild,
|
||||
contentChildren,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
Signal,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
||||
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
|
||||
import {
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
ConnectedPosition,
|
||||
ScrollStrategyOptions,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { DropdownAppearance } from './dropdown.types';
|
||||
import { DropdownService } from './dropdown.service';
|
||||
import { CloseOnScrollDirective } from '@isa/ui/layout';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
import { DropdownOptionComponent } from './dropdown-option.component';
|
||||
import { DropdownFilterComponent } from './dropdown-filter.component';
|
||||
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-dropdown',
|
||||
templateUrl: './dropdown.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
hostDirectives: [
|
||||
CdkOverlayOrigin,
|
||||
{
|
||||
directive: CloseOnScrollDirective,
|
||||
outputs: ['closeOnScroll'],
|
||||
},
|
||||
],
|
||||
imports: [NgIconComponent, CdkConnectedOverlay],
|
||||
providers: [
|
||||
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: DropdownButtonComponent,
|
||||
multi: true,
|
||||
},
|
||||
// Provide self for child DropdownOptionComponent to inject
|
||||
{
|
||||
provide: DROPDOWN_HOST,
|
||||
useExisting: DropdownButtonComponent,
|
||||
},
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
'["ui-dropdown", appearanceClass(), isOpenClass(), disabledClass(), valueClass()]',
|
||||
'role': 'combobox',
|
||||
'aria-haspopup': 'listbox',
|
||||
'[attr.id]': 'id()',
|
||||
'[attr.tabindex]': 'disabled() ? -1 : tabIndex()',
|
||||
'aria-expanded': 'isOpen()',
|
||||
'(keydown)': 'keyManager?.onKeydown($event)',
|
||||
'(keydown.enter)': 'select(keyManager!.activeItem); close()',
|
||||
'(keydown.escape)': 'close()',
|
||||
'(keydown.space)': 'onSpaceKey($event)',
|
||||
'(click)':
|
||||
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
|
||||
'(closeOnScroll)': 'close()',
|
||||
},
|
||||
})
|
||||
export class DropdownButtonComponent<T>
|
||||
implements ControlValueAccessor, AfterViewInit, DropdownHost<T>
|
||||
{
|
||||
#logger = logger({ component: 'DropdownButtonComponent' });
|
||||
#dropdownService = inject(DropdownService);
|
||||
#scrollStrategy = inject(ScrollStrategyOptions);
|
||||
#closeOnScroll = inject(CloseOnScrollDirective, { self: true });
|
||||
|
||||
readonly init = signal(false);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
/** Reference to the options panel for scroll exclusion */
|
||||
optionsPanel = viewChild<ElementRef<HTMLElement>>('optionsPanel');
|
||||
|
||||
get overlayMinWidth() {
|
||||
return this.elementRef.nativeElement.offsetWidth;
|
||||
}
|
||||
|
||||
get blockScrollStrategy() {
|
||||
return this.#scrollStrategy.block();
|
||||
}
|
||||
|
||||
/** Offset in pixels between the trigger and the overlay panel */
|
||||
readonly #overlayOffset = 12;
|
||||
|
||||
/**
|
||||
* Position priority for the overlay panel.
|
||||
* Order: bottom-left, bottom-right, top-left, top-right,
|
||||
* right-top, right-bottom, left-top, left-bottom
|
||||
*/
|
||||
readonly overlayPositions: ConnectedPosition[] = [
|
||||
// Bottom left
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: this.#overlayOffset,
|
||||
},
|
||||
// Bottom right
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetY: this.#overlayOffset,
|
||||
},
|
||||
// Top left
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -this.#overlayOffset,
|
||||
},
|
||||
// Top right
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -this.#overlayOffset,
|
||||
},
|
||||
// Right top
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetX: this.#overlayOffset,
|
||||
},
|
||||
// Right bottom
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetX: this.#overlayOffset,
|
||||
},
|
||||
// Left top
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetX: -this.#overlayOffset,
|
||||
},
|
||||
// Left bottom
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetX: -this.#overlayOffset,
|
||||
},
|
||||
];
|
||||
|
||||
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
||||
|
||||
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
|
||||
|
||||
disabledClass = computed(() => (this.disabled() ? 'disabled' : ''));
|
||||
|
||||
valueClass = computed(() => (this.value() ? 'has-value' : ''));
|
||||
|
||||
id = input<string>();
|
||||
|
||||
value = model<T | null>(null);
|
||||
|
||||
filter = model<string>('');
|
||||
|
||||
tabIndex = input<number>(0);
|
||||
|
||||
label = input<string>();
|
||||
|
||||
disabled = model<boolean>(false);
|
||||
|
||||
showSelectedValue = input<boolean>(true);
|
||||
|
||||
equals = input(isEqual);
|
||||
|
||||
options = contentChildren(DropdownOptionComponent);
|
||||
|
||||
/** Optional filter component projected as content */
|
||||
filterComponent = contentChild(DropdownFilterComponent);
|
||||
|
||||
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
|
||||
|
||||
selectedOption = computed(() => {
|
||||
const options = this.options();
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
const currentValue = this.value();
|
||||
const equalsFn = this.equals();
|
||||
return options.find((option) => equalsFn(option.value(), currentValue));
|
||||
});
|
||||
|
||||
keyManager?: ActiveDescendantKeyManager<DropdownOptionComponent<T>>;
|
||||
|
||||
onChange?: (value: T | null) => void;
|
||||
|
||||
onTouched?: () => void;
|
||||
|
||||
isOpen = signal(false);
|
||||
|
||||
isOpenClass = computed(() => (this.isOpen() ? 'open' : ''));
|
||||
|
||||
isOpenIcon = computed(() =>
|
||||
this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown',
|
||||
);
|
||||
|
||||
viewLabel = computed(() => {
|
||||
if (!this.showSelectedValue()) {
|
||||
return this.label() ?? this.value();
|
||||
}
|
||||
|
||||
const selectedOption = this.selectedOption();
|
||||
|
||||
if (!selectedOption || selectedOption.value() === null) {
|
||||
return this.label() ?? this.value();
|
||||
}
|
||||
|
||||
return selectedOption.getLabel();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.init()) {
|
||||
return;
|
||||
}
|
||||
this.keyManager?.destroy();
|
||||
this.keyManager = new ActiveDescendantKeyManager<
|
||||
DropdownOptionComponent<T>
|
||||
>(this.options())
|
||||
.withWrap()
|
||||
.skipPredicate((option) => option.disabled || option.isFiltered());
|
||||
});
|
||||
|
||||
// Configure CloseOnScrollDirective: activate when open, exclude options panel
|
||||
effect(() => {
|
||||
this.#closeOnScroll.closeOnScrollWhen.set(this.isOpen());
|
||||
this.#closeOnScroll.closeOnScrollExclude.set(
|
||||
this.optionsPanel()?.nativeElement,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.#logger.debug('Opening dropdown');
|
||||
const selected = this.selectedOption();
|
||||
if (selected) {
|
||||
this.keyManager?.setActiveItem(selected);
|
||||
} else {
|
||||
this.keyManager?.setFirstItemActive();
|
||||
}
|
||||
this.#dropdownService.open(this); // #5298 Fix
|
||||
this.isOpen.set(true);
|
||||
|
||||
// Focus filter input if present (after overlay renders)
|
||||
const filterComp = this.filterComponent();
|
||||
if (filterComp) {
|
||||
setTimeout(() => filterComp.focus());
|
||||
}
|
||||
}
|
||||
|
||||
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": [
|
||||
"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": [
|
||||
"libs/checkout/shared/product-info/src/index.ts"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user