Multi-Column ComboBox
<oge-multi-column-combo-box> is a combo box whose popup is a small data grid: columns with captions, widths, Intl formats and cell templates, a sticky header, row and cell keyboard navigation, search across the chosen columns, virtual scrolling and remote paged data — single or multiple selection, with signals, Signal Forms or reactive forms.
Basic usage
Give it [columns] — field (dot-notation), caption, width and format — plus the select box mapping. Typing searches every searchable column by its formatted text; the field shows displayExpr (default: the first column).
3import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeMultiColumnComboBox } from '@oge-ui/inputs';
import type { OgeComboBoxColumn } from '@oge-ui/inputs';
interface Product {
id: number;
sku: string;
name: string;
category: string;
price: number;
stock: number;
}
const PRODUCTS: Product[] = [
{ id: 1, sku: 'L-100', name: 'Desk lamp', category: 'Lighting', price: 49, stock: 12 },
{ id: 2, sku: 'C-200', name: 'Office chair', category: 'Seating', price: 249, stock: 0 },
{ id: 3, sku: 'M-300', name: 'Monitor arm', category: 'Accessories', price: 89, stock: 31 },
{ id: 4, sku: 'D-400', name: 'Standing desk', category: 'Desks', price: 699, stock: 4 },
{ id: 5, sku: 'K-500', name: 'Keyboard tray', category: 'Accessories', price: 39, stock: 57 },
];
@Component({
selector: 'demo-root',
imports: [OgeMultiColumnComboBox],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-multi-column-combo-box
label="Product"
[items]="products"
[columns]="columns"
valueExpr="id"
displayExpr="name"
[showClearButton]="true"
[(value)]="productId"
/>
`,
})
export class Demo {
protected readonly products = PRODUCTS;
protected readonly productId = signal<unknown>(3);
protected readonly columns: OgeComboBoxColumn<Product>[] = [
{ field: 'sku', caption: 'SKU', width: 80 },
{ field: 'name', width: 160 },
{ field: 'category', width: 120 },
{
field: 'price',
width: 90,
alignment: 'end',
format: { style: 'currency', currency: 'EUR' },
searchable: false,
},
];
}Cell templates & formats
A column's cellTemplate takes over the cell (it keeps its gridcell role); format also accepts a function of the raw value and the row. disabledExpr makes rows inert.
import { ChangeDetectionStrategy, Component, TemplateRef, computed, signal, viewChild } from '@angular/core';
import { OgeMultiColumnComboBox } from '@oge-ui/inputs';
import type { OgeComboBoxColumn, OgeComboBoxCellTemplateContext } from '@oge-ui/inputs';
interface Product {
id: number;
sku: string;
name: string;
category: string;
price: number;
stock: number;
}
const PRODUCTS: Product[] = [
{ id: 1, sku: 'L-100', name: 'Desk lamp', category: 'Lighting', price: 49, stock: 12 },
{ id: 2, sku: 'C-200', name: 'Office chair', category: 'Seating', price: 249, stock: 0 },
{ id: 3, sku: 'M-300', name: 'Monitor arm', category: 'Accessories', price: 89, stock: 31 },
{ id: 4, sku: 'D-400', name: 'Standing desk', category: 'Desks', price: 699, stock: 4 },
{ id: 5, sku: 'K-500', name: 'Keyboard tray', category: 'Accessories', price: 39, stock: 57 },
];
@Component({
selector: 'demo-root',
imports: [OgeMultiColumnComboBox],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-multi-column-combo-box
label="Product"
[items]="products"
[columns]="columns()"
valueExpr="id"
disabledExpr="soldOut"
[(value)]="productId"
/>
<ng-template #stockCell let-value="value">
<span [class.text-red-600]="value === 0">
{{ value === 0 ? 'Sold out' : value + ' in stock' }}
</span>
</ng-template>
`,
})
export class Demo {
protected readonly products = PRODUCTS.map((p) => ({
...p,
soldOut: p.stock === 0,
}));
protected readonly productId = signal<unknown>(null);
private readonly stockCell =
viewChild.required<TemplateRef<OgeComboBoxCellTemplateContext<Product>>>('stockCell');
/** A column's cellTemplate takes over the cell; it keeps its gridcell role. */
protected readonly columns = computed<OgeComboBoxColumn<Product>[]>(() => [
{ field: 'name', width: 160 },
{
field: 'stock',
caption: 'Availability',
width: 130,
cellTemplate: this.stockCell(),
},
{
field: 'price',
width: 90,
alignment: 'end',
format: (value) => `€${Number(value).toFixed(2)}`,
},
]);
}Multiple selection
selectionMode="multiple" makes value an array, renders removable chips (Backspace removes the last one), keeps the popup open while picking and sets aria-multiselectable.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeMultiColumnComboBox } from '@oge-ui/inputs';
import type { OgeComboBoxColumn } from '@oge-ui/inputs';
interface Product {
id: number;
sku: string;
name: string;
category: string;
price: number;
stock: number;
}
const PRODUCTS: Product[] = [
{ id: 1, sku: 'L-100', name: 'Desk lamp', category: 'Lighting', price: 49, stock: 12 },
{ id: 2, sku: 'C-200', name: 'Office chair', category: 'Seating', price: 249, stock: 0 },
{ id: 3, sku: 'M-300', name: 'Monitor arm', category: 'Accessories', price: 89, stock: 31 },
{ id: 4, sku: 'D-400', name: 'Standing desk', category: 'Desks', price: 699, stock: 4 },
{ id: 5, sku: 'K-500', name: 'Keyboard tray', category: 'Accessories', price: 39, stock: 57 },
];
@Component({
selector: 'demo-root',
imports: [OgeMultiColumnComboBox],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-multi-column-combo-box
label="Bundle"
[items]="products"
[columns]="columns"
valueExpr="id"
displayExpr="name"
selectionMode="multiple"
[maxDisplayedTags]="2"
[(value)]="bundle"
/>
`,
})
export class Demo {
protected readonly products = PRODUCTS;
protected readonly bundle = signal<unknown>([1, 3]);
protected readonly columns: OgeComboBoxColumn<Product>[] = [
{ field: 'sku', caption: 'SKU', width: 80 },
{ field: 'name', width: 160 },
{ field: 'price', width: 90, alignment: 'end', format: { style: 'currency', currency: 'EUR' } },
];
}Remote data
10,000 accounts behind a CustomDataSource: [dataSource] pages 50 rows at a time as the virtual window scrolls, sends the typed text as searchText (debounced, superseded requests aborted) and caches each search.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeMultiColumnComboBox } from '@oge-ui/inputs';
import { CustomDataSource } from '@oge-ui/core';
import type { OgeComboBoxColumn } from '@oge-ui/inputs';
interface Account {
id: number;
code: string;
name: string;
city: string;
}
const ACCOUNTS: Account[] = Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
code: `AC-${String(i + 1).padStart(5, '0')}`,
name: `Account ${i + 1}`,
city: ['Ankara', 'Berlin', 'Lisbon', 'Oslo'][i % 4],
}));
@Component({
selector: 'demo-root',
imports: [OgeMultiColumnComboBox],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-multi-column-combo-box
label="Account"
[dataSource]="accounts"
[columns]="columns"
valueExpr="id"
displayExpr="name"
[virtualScroll]="true"
[pageSize]="50"
[(value)]="accountId"
/>
`,
})
export class Demo {
protected readonly accountId = signal<unknown>(null);
protected readonly columns: OgeComboBoxColumn<Account>[] = [
{ field: 'code', width: 110 },
{ field: 'name', width: 150 },
{ field: 'city', width: 100 },
];
/** Paged on the server: skip/take + searchText, superseded requests aborted. */
protected readonly accounts = new CustomDataSource<Account>({
key: 'id',
load: async ({ skip = 0, take = 50, searchText }) => {
await new Promise((resolve) => setTimeout(resolve, 300));
const term = (searchText ?? '').toLowerCase();
const rows = ACCOUNTS.filter((a) =>
[a.code, a.name, a.city].some((text) => text.toLowerCase().includes(term)),
);
return { data: rows.slice(skip, skip + take), totalCount: rows.length };
},
});
}Forms
A full form editor: ControlValueAccessor and Signal Forms FormValueControl — [formField] carries required/touched/errors into the field chrome.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, form, required } from '@angular/forms/signals';
import { OgeMultiColumnComboBox } from '@oge-ui/inputs';
import type { OgeComboBoxColumn } from '@oge-ui/inputs';
interface Product {
id: number;
sku: string;
name: string;
category: string;
price: number;
stock: number;
}
const PRODUCTS: Product[] = [
{ id: 1, sku: 'L-100', name: 'Desk lamp', category: 'Lighting', price: 49, stock: 12 },
{ id: 2, sku: 'C-200', name: 'Office chair', category: 'Seating', price: 249, stock: 0 },
{ id: 3, sku: 'M-300', name: 'Monitor arm', category: 'Accessories', price: 89, stock: 31 },
{ id: 4, sku: 'D-400', name: 'Standing desk', category: 'Desks', price: 699, stock: 4 },
{ id: 5, sku: 'K-500', name: 'Keyboard tray', category: 'Accessories', price: 39, stock: 57 },
];
@Component({
selector: 'demo-root',
imports: [FormField, OgeMultiColumnComboBox],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-multi-column-combo-box
label="Product"
[items]="products"
[columns]="columns"
valueExpr="id"
displayExpr="name"
[formField]="order.productId"
/>
`,
})
export class Demo {
protected readonly products = PRODUCTS;
protected readonly columns: OgeComboBoxColumn<Product>[] = [
{ field: 'sku', caption: 'SKU', width: 80 },
{ field: 'name', width: 160 },
];
protected readonly model = signal<{ productId: number | null }>({ productId: null });
protected readonly order = form(this.model, (path) => required(path.productId));
}Keyboard & accessibility
The editor implements the WAI-ARIA APG combobox with grid popup pattern: the input is a role="combobox" with aria-haspopup="grid", DOM focus never leaves it, and aria-activedescendant names the active gridcell. Rows carry aria-selected and aria-rowindex; the grid reports aria-rowcount (the server total in remote mode, or -1 while it is open-ended) and aria-colcount.
- ↓/↑ open the popup and move between rows; PgUp/PgDn jump ten rows.
- Once the keyboard is in the grid, ←/→ move between cells (mirrored in RTL), Home/End go to the first/last cell and Ctrl+Home/End to the first/last row. Before that they move the text caret.
- Enter commits the active row (Space too, after navigating); Alt+↑ commits and closes; Esc closes, then clears the search.
- In
multiplemode Backspace on an empty search removes the last chip.