Transfer List
A dual list box — Kendo's ListBox toolbar, PrimeNG's PickList — as one form editor. Both sides are multiple-selection oge-list-boxes with every listbox key; the four buttons move the selection or everything a side shows, Ctrl+→ / ← on a focused list moves its selection toward the other list (with Shift: everything — mirrored in RTL, advertised with aria-keyshortcuts), and options drag between the lists with mouse, pen or touch.
Every path runs one move: the cancelable moving pre-event, the commit, a polite live announcement, then moved. The value is the target side's valueExpr results in arrival order; works with [(value)], [formField] and formControl/ngModel.
Getting started
Select options and use the buttons, press Ctrl+→ / Ctrl+← on a focused list (add Shift to move everything), or drag an option onto the other list. The locked permission stays where it is.
Value: ["orders.read"]
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeTransferList } from '@oge-ui/inputs';
interface Permission {
id: string;
name: string;
area: string;
locked?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTransferList],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- The value is the target side: the moved values in arrival order.
Buttons move the selection / everything shown; Ctrl+→ / Ctrl+← on a
focused list move its selection (Shift: everything), and options drag
between the lists. Locked items stay where they are. -->
<oge-transfer-list
label="Role permissions"
[items]="permissions"
displayExpr="name"
valueExpr="id"
disabledExpr="locked"
targetTitle="Granted"
[height]="240"
[(value)]="granted"
/>
`,
})
export class Demo {
protected readonly permissions: Permission[] = [
{ id: 'orders.read', name: 'View orders', area: 'Orders' },
{ id: 'orders.write', name: 'Edit orders', area: 'Orders' },
{ id: 'orders.refund', name: 'Issue refunds', area: 'Orders' },
{ id: 'users.read', name: 'View users', area: 'Users' },
{ id: 'users.invite', name: 'Invite users', area: 'Users' },
{ id: 'users.admin', name: 'Manage roles', area: 'Users', locked: true },
{ id: 'reports.read', name: 'View reports', area: 'Reports' },
{ id: 'reports.export', name: 'Export reports', area: 'Reports' },
];
protected readonly granted = signal<readonly unknown[]>(['orders.read']);
}Search and check boxes
A search field above each list; the move-all buttons move what the list currently shows. Check glyphs make the multiple selection explicit.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeTransferList } from '@oge-ui/inputs';
interface Permission {
id: string;
name: string;
area: string;
locked?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTransferList],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- A search field per list; "move all" moves what the list shows. -->
<oge-transfer-list
[items]="permissions"
displayExpr="name"
valueExpr="id"
[searchEnabled]="true"
[showCheckBoxes]="true"
[height]="220"
[(value)]="granted"
/>
`,
})
export class Demo {
protected readonly permissions: Permission[] = [
{ id: 'orders.read', name: 'View orders', area: 'Orders' },
{ id: 'orders.write', name: 'Edit orders', area: 'Orders' },
{ id: 'orders.refund', name: 'Issue refunds', area: 'Orders' },
{ id: 'users.read', name: 'View users', area: 'Users' },
{ id: 'users.invite', name: 'Invite users', area: 'Users' },
{ id: 'users.admin', name: 'Manage roles', area: 'Users', locked: true },
{ id: 'reports.read', name: 'View reports', area: 'Reports' },
{ id: 'reports.export', name: 'Export reports', area: 'Reports' },
];
protected readonly granted = signal<readonly unknown[]>([]);
}Groups and templates
groupBy and the list box templates ([ogeListBoxItemTemplate], [ogeListBoxGroupTemplate]) apply to both lists.
orders.readorders.writeorders.refundusers.readusers.inviteusers.adminreports.exportreports.readimport { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeTransferList, OgeListBoxItemTemplate } from '@oge-ui/inputs';
interface Permission {
id: string;
name: string;
area: string;
locked?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTransferList, OgeListBoxItemTemplate],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- groupBy and the list box templates apply to both lists. -->
<oge-transfer-list
[items]="permissions"
displayExpr="name"
valueExpr="id"
groupBy="area"
[height]="280"
[(value)]="granted"
>
<ng-template ogeListBoxItemTemplate let-permission>
<span>{{ permission.name }}</span>
<code>{{ permission.id }}</code>
</ng-template>
</oge-transfer-list>
`,
})
export class Demo {
protected readonly permissions: Permission[] = [
{ id: 'orders.read', name: 'View orders', area: 'Orders' },
{ id: 'orders.write', name: 'Edit orders', area: 'Orders' },
{ id: 'orders.refund', name: 'Issue refunds', area: 'Orders' },
{ id: 'users.read', name: 'View users', area: 'Users' },
{ id: 'users.invite', name: 'Invite users', area: 'Users' },
{ id: 'users.admin', name: 'Manage roles', area: 'Users', locked: true },
{ id: 'reports.read', name: 'View reports', area: 'Reports' },
{ id: 'reports.export', name: 'Export reports', area: 'Reports' },
];
protected readonly granted = signal<readonly unknown[]>(['reports.read']);
}Cancelable moves
moving fires before every move with cause ('button' | 'keyboard' | 'drag') — set cancel to veto it. Here the target side holds at most three permissions.
Log: —
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeTransferList } from '@oge-ui/inputs';
import type { OgeTransferListMovingEvent } from '@oge-ui/inputs';
interface Permission {
id: string;
name: string;
area: string;
locked?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTransferList],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- moving is cancelable (cancel = true vetoes the move, whatever its
cause: button, keyboard or drag); moved reports what changed. -->
<oge-transfer-list
[items]="permissions"
displayExpr="name"
valueExpr="id"
targetTitle="Granted (max 3)"
[height]="240"
[(value)]="granted"
(moving)="limit($event)"
(moved)="log.set($event.values.length + ' moved by ' + $event.cause)"
/>
<p>{{ log() }}</p>
`,
})
export class Demo {
protected readonly permissions: Permission[] = [
{ id: 'orders.read', name: 'View orders', area: 'Orders' },
{ id: 'orders.write', name: 'Edit orders', area: 'Orders' },
{ id: 'orders.refund', name: 'Issue refunds', area: 'Orders' },
{ id: 'users.read', name: 'View users', area: 'Users' },
{ id: 'users.invite', name: 'Invite users', area: 'Users' },
{ id: 'users.admin', name: 'Manage roles', area: 'Users', locked: true },
{ id: 'reports.read', name: 'View reports', area: 'Reports' },
{ id: 'reports.export', name: 'Export reports', area: 'Reports' },
];
protected readonly granted = signal<readonly unknown[]>([]);
protected readonly log = signal('');
protected limit(event: OgeTransferListMovingEvent<Permission>): void {
if (event.to === 'target' && this.granted().length + event.values.length > 3) {
event.cancel = true;
this.log.set('Vetoed: at most 3 permissions');
}
}
}Inside a form
A FormValueControl and a ControlValueAccessor — the bound control holds the target side's values.
Valid: false
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeTransferList } from '@oge-ui/inputs';
import { ReactiveFormsModule, FormControl, Validators } from '@angular/forms';
interface Permission {
id: string;
name: string;
area: string;
locked?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTransferList, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- FormValueControl + ControlValueAccessor — the control holds the
target side's values. -->
<oge-transfer-list
label="Granted permissions"
hint="Grant at least one"
[items]="permissions"
displayExpr="name"
valueExpr="id"
[height]="200"
[formControl]="granted"
/>
`,
})
export class Demo {
protected readonly permissions: Permission[] = [
{ id: 'orders.read', name: 'View orders', area: 'Orders' },
{ id: 'orders.write', name: 'Edit orders', area: 'Orders' },
{ id: 'orders.refund', name: 'Issue refunds', area: 'Orders' },
{ id: 'users.read', name: 'View users', area: 'Users' },
{ id: 'users.invite', name: 'Invite users', area: 'Users' },
{ id: 'users.admin', name: 'Manage roles', area: 'Users', locked: true },
{ id: 'reports.read', name: 'View reports', area: 'Reports' },
{ id: 'reports.export', name: 'Export reports', area: 'Reports' },
];
protected readonly granted = new FormControl<string[]>([], Validators.required);
}