diff --git a/packages/components-dev/list/module.ts b/packages/components-dev/list/module.ts
index f322a0b1c6..0080e962c8 100644
--- a/packages/components-dev/list/module.ts
+++ b/packages/components-dev/list/module.ts
@@ -1,4 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard';
+import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { AsyncPipe, JsonPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
@@ -6,7 +7,7 @@ import { FormsModule, UntypedFormControl } from '@angular/forms';
import { PopUpPlacements } from '@koobiq/components/core';
import { KbqDropdownModule } from '@koobiq/components/dropdown';
import { KbqIconModule } from '@koobiq/components/icon';
-import { KbqListModule, KbqListSelectionChange } from '@koobiq/components/list';
+import { KbqListModule, KbqListSelectionChange, KbqListSelectionDroppedEvent } from '@koobiq/components/list';
import { KbqTitleModule } from '@koobiq/components/title';
import { KbqToolTipModule } from '@koobiq/components/tooltip';
import { ListExamplesModule } from 'packages/docs-examples/components/list';
@@ -14,6 +15,11 @@ import { of } from 'rxjs';
import { debounceTime, startWith, switchMap } from 'rxjs/operators';
import { DevThemeToggle } from '../theme-toggle';
+type DevItem = { id: number; label: string };
+
+const devItems = (prefix: string, offset: number, length: number): DevItem[] =>
+ Array.from({ length }, (_, i) => ({ id: offset + i, label: `${prefix} #${offset + i}` }));
+
@Component({
selector: 'dev-examples',
imports: [ListExamplesModule],
@@ -28,6 +34,12 @@ import { DevThemeToggle } from '../theme-toggle';
+
+
+
+
+
+
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -58,6 +70,12 @@ export class DevApp {
list = signal(Array.from({ length: 5 }, (_, i) => `Item ${i}`));
+ readonly draggableItems = signal(devItems('Task', 0, 5));
+ draggableSelected: DevItem[] = [];
+
+ readonly availableItems = signal(devItems('Available', 0, 4));
+ readonly chosenItems = signal(devItems('Chosen', 10, 3));
+
readonly options = Array.from({ length: 10000 }).map((_, i) => ({
id: i,
label: `Option #${i}`
@@ -97,4 +115,35 @@ export class DevApp {
onRemove(item: string) {
this.list.update((list) => list.filter((listItem) => listItem !== item));
}
+
+ onDropped({ previousIndex, currentIndex }: KbqListSelectionDroppedEvent) {
+ const items = [...this.draggableItems()];
+
+ moveItemInArray(items, previousIndex, currentIndex);
+
+ this.draggableItems.set(items);
+ }
+
+ onTransferred({ previousIndex, currentIndex, previousContainer, container, option }: KbqListSelectionDroppedEvent) {
+ const fromAvailable = this.availableItems().includes(option.value);
+ const source = fromAvailable ? this.availableItems : this.chosenItems;
+
+ if (previousContainer === container) {
+ const items = [...source()];
+
+ moveItemInArray(items, previousIndex, currentIndex);
+ source.set(items);
+
+ return;
+ }
+
+ const target = fromAvailable ? this.chosenItems : this.availableItems;
+ const from = [...source()];
+ const to = [...target()];
+
+ transferArrayItem(from, to, previousIndex, currentIndex);
+
+ source.set(from);
+ target.set(to);
+ }
}
diff --git a/packages/components-dev/list/template.html b/packages/components-dev/list/template.html
index 98b697fba9..bed50fd1f4 100644
--- a/packages/components-dev/list/template.html
+++ b/packages/components-dev/list/template.html
@@ -4,6 +4,48 @@
+draggable — reorder within one list
+
+ @for (item of draggableItems(); track item.id) {
+ {{ item.label }}
+ }
+
+order: {{ draggableItems() | json }}
+
+draggable — transfer between connected lists
+
+
+ @for (item of availableItems(); track item.id) {
+ {{ item.label }}
+ }
+
+
+ @for (item of chosenItems(); track item.id) {
+ {{ item.label }}
+ }
+
+
+
+
single selection
diff --git a/packages/components/core/locales/en-US.ts b/packages/components/core/locales/en-US.ts
index 0d04ee509e..db7d2d70b3 100644
--- a/packages/components/core/locales/en-US.ts
+++ b/packages/components/core/locales/en-US.ts
@@ -21,7 +21,8 @@ export const enUSLocaleData = {
nextMonth: 'Next month',
clear: 'Clear',
showPassword: 'Show password',
- hidePassword: 'Hide password'
+ hidePassword: 'Hide password',
+ listOptionMoved: '{{ label }}, position {{ index }} of {{ total }}'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Select all' } satisfies KbqSelectLocaleConfiguration,
datepicker: {
diff --git a/packages/components/core/locales/es-LA.ts b/packages/components/core/locales/es-LA.ts
index 876878ff45..0cc79114fa 100644
--- a/packages/components/core/locales/es-LA.ts
+++ b/packages/components/core/locales/es-LA.ts
@@ -21,7 +21,8 @@ export const esLALocaleData = {
nextMonth: 'Mes siguiente',
clear: 'Borrar',
showPassword: 'Mostrar la contraseña',
- hidePassword: 'Ocultar la contraseña'
+ hidePassword: 'Ocultar la contraseña',
+ listOptionMoved: '{{ label }}, posición {{ index }} de {{ total }}'
} satisfies KbqA11yLocaleConfiguration,
select: {
hiddenItemsText: '+{{ number }}',
diff --git a/packages/components/core/locales/pt-BR.ts b/packages/components/core/locales/pt-BR.ts
index 8cad608f86..0417014c11 100644
--- a/packages/components/core/locales/pt-BR.ts
+++ b/packages/components/core/locales/pt-BR.ts
@@ -21,7 +21,8 @@ export const ptBRLocaleData = {
nextMonth: 'Próximo mês',
clear: 'Apagar',
showPassword: 'Mostrar a senha',
- hidePassword: 'Ocultar a senha'
+ hidePassword: 'Ocultar a senha',
+ listOptionMoved: '{{ label }}, posição {{ index }} de {{ total }}'
} satisfies KbqA11yLocaleConfiguration,
select: {
hiddenItemsText: '+{{ number }}',
diff --git a/packages/components/core/locales/ru-RU.ts b/packages/components/core/locales/ru-RU.ts
index 39e7702567..4d29317c2c 100644
--- a/packages/components/core/locales/ru-RU.ts
+++ b/packages/components/core/locales/ru-RU.ts
@@ -21,7 +21,8 @@ export const ruRULocaleData = {
nextMonth: 'Следующий месяц',
clear: 'Очистить',
showPassword: 'Показать пароль',
- hidePassword: 'Скрыть пароль'
+ hidePassword: 'Скрыть пароль',
+ listOptionMoved: '{{ label }}, позиция {{ index }} из {{ total }}'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Выбрать все' } satisfies KbqSelectLocaleConfiguration,
datepicker: {
diff --git a/packages/components/core/locales/tk-TM.ts b/packages/components/core/locales/tk-TM.ts
index d99ba46f63..cf887b044d 100644
--- a/packages/components/core/locales/tk-TM.ts
+++ b/packages/components/core/locales/tk-TM.ts
@@ -21,7 +21,8 @@ export const tkTMLocaleData = {
nextMonth: 'Indiki aý',
clear: 'Arassala',
showPassword: 'Paroly görkez',
- hidePassword: 'Paroly gizle'
+ hidePassword: 'Paroly gizle',
+ listOptionMoved: '{{ label }}, {{ total }} ýerden {{ index }}-nji ýer'
} satisfies KbqA11yLocaleConfiguration,
select: {
hiddenItemsText: '+{{ number }}',
diff --git a/packages/components/core/locales/types.ts b/packages/components/core/locales/types.ts
index 7af586899d..0585389bbf 100644
--- a/packages/components/core/locales/types.ts
+++ b/packages/components/core/locales/types.ts
@@ -1,10 +1,11 @@
import { FormatterDurationTemplate } from '@koobiq/date-formatter';
/**
- * Accessible names for the icon-only buttons the library renders itself.
+ * Text the library exposes to assistive tech only: accessible names for the icon-only buttons it
+ * renders itself, and live-region announcements.
*
* An icon carries no text, so without one of these a button has no accessible name at all (AXE
- * `button-name`). They are announced by assistive tech and are never displayed.
+ * `button-name`). None of these strings are ever displayed.
*/
export type KbqA11yLocaleConfiguration = {
/** Close button of a modal, popover, sidepanel, content panel or notification center. */
@@ -29,6 +30,11 @@ export type KbqA11yLocaleConfiguration = {
showPassword: string;
/** Password form field button that masks the password. */
hidePassword: string;
+ /**
+ * Announced after an option of a draggable `kbq-list-selection` has been reordered.
+ * Supports the `{{ label }}`, `{{ index }}` and `{{ total }}` placeholders.
+ */
+ listOptionMoved: string;
};
/** Locale configuration for `KbqCodeBlockModule`. */
diff --git a/packages/components/list/e2e.playwright-spec.ts b/packages/components/list/e2e.playwright-spec.ts
index 1f03e7de1f..201afde710 100644
--- a/packages/components/list/e2e.playwright-spec.ts
+++ b/packages/components/list/e2e.playwright-spec.ts
@@ -117,4 +117,194 @@ test.describe('KbqListModule', () => {
await expect(getOptionAction(page, 'option-1')).toBeVisible();
});
});
+
+ test.describe('E2eListDragAndDrop', () => {
+ const getLabels = (page: Page, list: string) =>
+ page.getByTestId(list).locator('kbq-list-option .kbq-list-text').allInnerTexts();
+
+ /**
+ * CDK only starts a drag past its 5px threshold, hence the stepped move rather than a single
+ * jump. The pointer stops past the target's midpoint rather than on it: the midpoint is exactly
+ * the boundary between the gap above the target and the gap below it, so aiming at the centre
+ * would leave the resulting position ambiguous.
+ */
+ const pressAndMoveOnto = async (page: Page, from: string, to: string) => {
+ const sourceBox = (await page.getByTestId(from).boundingBox())!;
+ const targetBox = (await page.getByTestId(to).boundingBox())!;
+ const startX = sourceBox.x + sourceBox.width / 2;
+ const startY = sourceBox.y + sourceBox.height / 2;
+ const endX = targetBox.x + targetBox.width / 2;
+ const endY = targetBox.y + targetBox.height * 0.75;
+
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+
+ for (let step = 1; step <= 10; step++) {
+ await page.mouse.move(startX + ((endX - startX) * step) / 10, startY + ((endY - startY) * step) / 10, {
+ steps: 2
+ });
+ }
+ };
+
+ /**
+ * The drop is asynchronous: CDK emits `cdkDropListDropped` only after the preview has animated
+ * onto the placeholder, so wait for the preview to be gone before asserting.
+ */
+ const dragOnto = async (page: Page, from: string, to: string) => {
+ await pressAndMoveOnto(page, from, to);
+ await page.mouse.up();
+ await expect(page.locator('.cdk-drag-preview')).toHaveCount(0);
+ };
+
+ /** Reads `label@top` of every option in the list — the layout the drag must never disturb. */
+ const readLayout = (page: Page, list: string) =>
+ page
+ .getByTestId(list)
+ .evaluate((container) =>
+ [...container.querySelectorAll('kbq-list-option')]
+ .map(
+ (option) =>
+ `${option.textContent!.trim()}@${Math.round(option.getBoundingClientRect().top)}`
+ )
+ .join(' ')
+ );
+
+ /** Samples `label@top` of every option once per animation frame for `duration` ms. */
+ const sampleFrames = (page: Page, list: string, duration: number) =>
+ page.evaluate(
+ ([testId, ms]) =>
+ new Promise((resolve) => {
+ const container = document.querySelector(`[data-testid="${testId}"]`)!;
+ const frames: string[] = [];
+ const start = performance.now();
+ const tick = () => {
+ frames.push(
+ [...container.querySelectorAll('kbq-list-option')]
+ .map((o) => `${o.textContent!.trim()}@${Math.round(o.getBoundingClientRect().top)}`)
+ .join(' ')
+ );
+
+ if (performance.now() - start < (ms as number)) {
+ requestAnimationFrame(tick);
+ } else {
+ resolve(frames);
+ }
+ };
+
+ requestAnimationFrame(tick);
+ }),
+ [list, duration] as const
+ );
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/E2eListDragAndDrop');
+ });
+
+ test('reorders options by dragging within one list', async ({ page }) => {
+ expect(await getLabels(page, 'e2eSourceList')).toEqual(['source-1', 'source-2', 'source-3']);
+
+ await dragOnto(page, 'source-1', 'source-3');
+
+ expect(await getLabels(page, 'e2eSourceList')).toEqual(['source-2', 'source-3', 'source-1']);
+ });
+
+ test('never moves the surrounding options while dragging', async ({ page }) => {
+ const atRest = await readLayout(page, 'e2eSourceList');
+
+ const sampling = sampleFrames(page, 'e2eSourceList', 400);
+
+ await pressAndMoveOnto(page, 'source-1', 'source-3');
+
+ // The target position is shown by the indicator, so the list itself must stay perfectly
+ // still: no option may be nudged aside to open a gap at any point during the drag.
+ expect([...new Set(await sampling)]).toEqual([atRest]);
+
+ await page.mouse.up();
+ });
+
+ test('marks the drop target with the insertion indicator', async ({ page }) => {
+ const indicator = page.getByTestId('e2eSourceList').locator('.kbq-list-selection__drop-indicator');
+
+ await expect(indicator).toHaveCount(0);
+
+ await pressAndMoveOnto(page, 'source-1', 'source-3');
+
+ await expect(indicator).toBeVisible();
+
+ // Dropping past the midpoint of the last option puts the indicator at the list's end.
+ const lastOption = (await page.getByTestId('source-3').boundingBox())!;
+ const indicatorBox = (await indicator.boundingBox())!;
+
+ expect(Math.abs(indicatorBox.y - (lastOption.y + lastOption.height))).toBeLessThanOrEqual(2);
+
+ await page.mouse.up();
+ await expect(indicator).toHaveCount(0);
+ });
+
+ test('keeps the dragged option in place, faded, instead of removing it', async ({ page }) => {
+ await pressAndMoveOnto(page, 'source-1', 'source-3');
+
+ // The row stays where it was so the list does not jump when the drag begins.
+ expect(await getLabels(page, 'e2eSourceList')).toEqual(['source-1', 'source-2', 'source-3']);
+ await expect(page.getByTestId('e2eSourceList').locator('.cdk-drag-placeholder')).toHaveCount(1);
+
+ await page.mouse.up();
+ });
+
+ test('moves the indicator into the connected list when hovering it', async ({ page }) => {
+ await pressAndMoveOnto(page, 'source-1', 'target-1');
+
+ await expect(
+ page.getByTestId('e2eTargetList').locator('.kbq-list-selection__drop-indicator')
+ ).toBeVisible();
+ await expect(page.getByTestId('e2eSourceList').locator('.kbq-list-selection__drop-indicator')).toHaveCount(
+ 0
+ );
+
+ await page.mouse.up();
+ });
+
+ test('does not select the option that was dragged', async ({ page }) => {
+ await dragOnto(page, 'source-1', 'source-3');
+
+ // A drag ends with a `mouseup` over the option, which must not read as a click-to-select.
+ await expect(page.getByTestId('e2eSourceList').locator('.kbq-selected')).toHaveCount(0);
+ });
+
+ test('still selects on a plain click', async ({ page }) => {
+ await page.getByTestId('source-1').click();
+
+ await expect(page.getByTestId('source-1')).toHaveClass(/kbq-selected/);
+ });
+
+ test('moves an option into the connected list', async ({ page }) => {
+ await dragOnto(page, 'source-1', 'target-1');
+
+ expect(await getLabels(page, 'e2eSourceList')).toEqual(['source-2', 'source-3']);
+ expect(await getLabels(page, 'e2eTargetList')).toContain('source-1');
+ });
+
+ test('reorders with the keyboard alone', async ({ page }) => {
+ await page.keyboard.press('Tab');
+ await expect(page.getByTestId('source-1')).toHaveClass(/kbq-focused/);
+
+ await page.keyboard.press('Alt+ArrowDown');
+
+ expect(await getLabels(page, 'e2eSourceList')).toEqual(['source-2', 'source-1', 'source-3']);
+ // Focus has to follow the option it moved, otherwise the next keypress acts on a different row.
+ await expect(page.getByTestId('source-1')).toBeFocused();
+ await expect(page.getByTestId('e2eSourceList').locator('[aria-live="polite"]')).toHaveText(
+ /source-1.*2.*3/
+ );
+ });
+
+ test('transfers to the connected list with the keyboard alone', async ({ page }) => {
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Alt+ArrowRight');
+
+ expect(await getLabels(page, 'e2eSourceList')).toEqual(['source-2', 'source-3']);
+ expect(await getLabels(page, 'e2eTargetList')).toEqual(['target-1', 'source-1']);
+ await expect(page.getByTestId('source-1')).toBeFocused();
+ });
+ });
});
diff --git a/packages/components/list/e2e.ts b/packages/components/list/e2e.ts
index 26c3a6b668..aff919b7e2 100644
--- a/packages/components/list/e2e.ts
+++ b/packages/components/list/e2e.ts
@@ -1,10 +1,11 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
+import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqBadgeModule } from '@koobiq/components/badge';
import { KbqOptionModule } from '@koobiq/components/core';
import { KbqDropdownModule } from '@koobiq/components/dropdown';
import { KbqIconModule } from '@koobiq/components/icon';
-import { KbqListModule } from '@koobiq/components/list';
+import { KbqListModule, KbqListSelectionDroppedEvent } from '@koobiq/components/list';
@Component({
selector: 'e2e-list-states',
@@ -279,3 +280,77 @@ export class E2eListSelectionState {
export class E2eListOptionActionVisibility {
protected readonly options = ['option-1', 'option-2', 'option-3'];
}
+
+/**
+ * Two connected draggable lists. Reordering never mutates the data on its own, so the fixture applies
+ * every `dropped` event itself — exactly what a consumer has to do.
+ */
+@Component({
+ selector: 'e2e-list-drag-and-drop',
+ imports: [KbqListModule],
+ template: `
+
+
+ @for (item of sourceItems(); track item) {
+ {{ item }}
+ }
+
+
+ @for (item of targetItems(); track item) {
+ {{ item }}
+ }
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-testid': 'e2eListDragAndDrop'
+ }
+})
+export class E2eListDragAndDrop {
+ protected readonly sourceItems = signal(['source-1', 'source-2', 'source-3']);
+ protected readonly targetItems = signal(['target-1']);
+
+ protected handleDropped({
+ previousIndex,
+ currentIndex,
+ previousContainer,
+ container,
+ option
+ }: KbqListSelectionDroppedEvent): void {
+ const fromSource = this.sourceItems().includes(option.value);
+ const source = fromSource ? this.sourceItems : this.targetItems;
+
+ if (previousContainer === container) {
+ const items = [...source()];
+
+ moveItemInArray(items, previousIndex, currentIndex);
+ source.set(items);
+
+ return;
+ }
+
+ const target = fromSource ? this.targetItems : this.sourceItems;
+ const from = [...source()];
+ const to = [...target()];
+
+ transferArrayItem(from, to, previousIndex, currentIndex);
+
+ source.set(from);
+ target.set(to);
+ }
+}
diff --git a/packages/components/list/list-selection.component.spec.ts b/packages/components/list/list-selection.component.spec.ts
index 1f00052380..d28120948d 100644
--- a/packages/components/list/list-selection.component.spec.ts
+++ b/packages/components/list/list-selection.component.spec.ts
@@ -1,5 +1,6 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import { Clipboard } from '@angular/cdk/clipboard';
+import { CdkDrag, CdkDropList, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -7,6 +8,7 @@ import {
DebugElement,
inject,
Provider,
+ signal,
Type,
viewChild,
viewChildren
@@ -29,20 +31,24 @@ import {
HOME,
KbqOptionActionComponent,
KbqOptionModule,
+ LEFT_ARROW,
PAGE_DOWN,
PAGE_UP,
+ RIGHT_ARROW,
SPACE,
TAB,
UP_ARROW
} from '@koobiq/components/core';
import { KbqDropdownModule } from '@koobiq/components/dropdown';
+import { axe } from 'jest-axe';
import {
KbqListCopyEvent,
KbqListModule,
KbqListOption,
KbqListSelectAllEvent,
KbqListSelection,
- KbqListSelectionChange
+ KbqListSelectionChange,
+ KbqListSelectionDroppedEvent
} from './index';
const getFocusMonitor = () => TestBed.inject(FocusMonitor);
@@ -1268,6 +1274,548 @@ describe('KbqListSelection onCopy event', () => {
}));
});
+describe('KbqListSelection drag and drop', () => {
+ const getDropList = (fixture: ComponentFixture) =>
+ fixture.debugElement.query(By.directive(KbqListSelection)).injector.get(CdkDropList);
+
+ const getDrags = (fixture: ComponentFixture) =>
+ fixture.debugElement.queryAll(By.directive(KbqListOption)).map((option) => option.injector.get(CdkDrag));
+
+ const getLabels = (fixture: ComponentFixture) =>
+ Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('kbq-list-option .kbq-list-text')).map(
+ (element) => element.textContent!.trim()
+ );
+
+ const altKeydown = (list: KbqListSelection, keyCode: number) => {
+ const event = createKeyboardEvent('keydown', keyCode);
+
+ Object.defineProperty(event, 'altKey', { get: () => true });
+ list.onKeyDown(event);
+
+ return event;
+ };
+
+ const getOptions = (fixture: ComponentFixture): KbqListOption[] =>
+ fixture.debugElement.queryAll(By.directive(KbqListOption)).map((option) => option.componentInstance);
+
+ /**
+ * jsdom performs no layout, so every rect is empty and the gap the indicator marks cannot be
+ * resolved. Stacks the options 20px apart, which puts their midpoints at 10, 30, 50, 70.
+ */
+ const stubVerticalLayout = (fixture: ComponentFixture, optionHeight = 20) => {
+ fixture.debugElement.queryAll(By.directive(KbqListSelection)).forEach(({ nativeElement }) => {
+ jest.spyOn(nativeElement as HTMLElement, 'getBoundingClientRect').mockReturnValue({
+ top: 0,
+ bottom: 1000,
+ left: 0,
+ right: 100
+ } as DOMRect);
+ });
+
+ getOptions(fixture).forEach((option, index) => {
+ jest.spyOn(option.getHostElement(), 'getBoundingClientRect').mockReturnValue({
+ top: index * optionHeight,
+ bottom: (index + 1) * optionHeight,
+ left: 0,
+ right: 100
+ } as DOMRect);
+ });
+ };
+
+ /** Replays what `CdkDropList` emits on drop; its own `currentIndex` is stale by design. */
+ const emitCdkDrop = (
+ fixture: ComponentFixture,
+ option: KbqListOption,
+ event = createMouseEvent('mouseup')
+ ) => {
+ const dropList = getDropList(fixture);
+
+ dropList.dropped.emit({
+ previousIndex: 0,
+ currentIndex: 0,
+ item: { data: { option } } as any,
+ container: dropList,
+ previousContainer: dropList,
+ isPointerOverContainer: true,
+ distance: { x: 0, y: 0 },
+ dropPoint: { x: 0, y: 0 },
+ event
+ });
+ fixture.detectChanges();
+ };
+
+ describe('opt-in wiring', () => {
+ it('should not be draggable by default', () => {
+ // A list with no `draggable` binding at all — the only way to exercise the real default.
+ const fixture = setup(SelectionListWithListOptions);
+
+ expect(fixture.nativeElement.querySelector('.kbq-list-selection_draggable')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.kbq-list-option_draggable')).toBeNull();
+ expect(getDropList(fixture).disabled).toBe(true);
+ expect(getDrags(fixture).every((drag) => drag.disabled)).toBe(true);
+ });
+
+ it('should stop being draggable once the input is set back to false', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+
+ fixture.componentInstance.draggable.set(false);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('.kbq-list-selection_draggable')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.kbq-list-option_draggable')).toBeNull();
+ expect(getDropList(fixture).disabled).toBe(true);
+ expect(getDrags(fixture).every((drag) => drag.disabled)).toBe(true);
+ });
+
+ it('should enable the underlying CDK directives when draggable', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+
+ expect(fixture.nativeElement.querySelector('.kbq-list-selection_draggable')).not.toBeNull();
+ expect(getDropList(fixture).disabled).toBe(false);
+ expect(getDrags(fixture).every((drag) => drag.disabled)).toBe(false);
+ });
+
+ it('should not be draggable while the list is disabled', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+
+ fixture.componentInstance.disabled.set(true);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.list().draggable).toBe(false);
+ expect(getDropList(fixture).disabled).toBe(true);
+ expect(getDrags(fixture).every((drag) => drag.disabled)).toBe(true);
+ });
+
+ it('should not drag a disabled option while the rest stay draggable', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+
+ fixture.componentInstance.disabledItem.set(fixture.componentInstance.items()[1]);
+ fixture.detectChanges();
+
+ expect(getDrags(fixture).map((drag) => drag.disabled)).toEqual([false, true, false, false]);
+ });
+
+ it('should delay a touch drag so that the list stays scrollable', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+
+ expect(getDrags(fixture)[0].dragStartDelay).toEqual({ touch: 300, mouse: 0 });
+ });
+
+ it('should connect the drop list to the lists passed to connectedTo', () => {
+ const fixture = setup(ConnectedSelectionLists);
+ const [first, second] = fixture.debugElement
+ .queryAll(By.directive(KbqListSelection))
+ .map((list) => list.injector.get(CdkDropList));
+
+ expect(first.connectedTo).toEqual([second]);
+ expect(second.connectedTo).toEqual([first]);
+ });
+
+ it('should connect the drop list by id and keep the consumer-set id', () => {
+ const fixture = setup(IdConnectedSelectionLists);
+ const [source, target] = fixture.debugElement
+ .queryAll(By.directive(KbqListSelection))
+ .map((list) => list.injector.get(CdkDropList));
+
+ // The id has to survive `CdkDropList`'s own `[attr.id]` binding to stay referenceable.
+ expect(source.id).toBe('source-list');
+ expect(target.id).toBe('target-list');
+ expect(source.connectedTo).toEqual(['target-list']);
+ });
+ });
+
+ describe('dropped output', () => {
+ it('should re-emit a CDK drop as a KbqListSelectionDroppedEvent', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+ const option = getOptions(fixture)[0];
+ const nativeEvent = createMouseEvent('mouseup');
+
+ stubVerticalLayout(fixture);
+ // Pointer past the midpoint of the third option, i.e. into the gap that follows it.
+ list.onOptionDragMoved(option, { x: 50, y: 55 });
+ emitCdkDrop(fixture, option, nativeEvent);
+
+ expect(fixture.componentInstance.dropped).toEqual({
+ previousIndex: 0,
+ currentIndex: 2,
+ option,
+ container: list,
+ previousContainer: list,
+ event: nativeEvent
+ });
+ });
+
+ it('should survive the ended event that CDK fires before the drop', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+ const option = getOptions(fixture)[0];
+
+ stubVerticalLayout(fixture);
+ list.onOptionDragMoved(option, { x: 50, y: 55 });
+
+ // `DragRef` emits `ended` immediately before `dropped`. Tearing the indicator down there
+ // would discard the resolved target index and silently turn every drag into a no-op.
+ getDrags(fixture)[0].ended.emit({
+ source: null!,
+ distance: { x: 0, y: 0 },
+ dropPoint: { x: 0, y: 0 },
+ event: createMouseEvent('mouseup')
+ });
+ emitCdkDrop(fixture, option);
+
+ expect(fixture.componentInstance.dropped!.currentIndex).toBe(2);
+ expect(getLabels(fixture)).toEqual(['Item 1', 'Item 2', 'Item 0', 'Item 3']);
+ });
+
+ it('should report a move that changes nothing when the pointer never entered a list', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const option = getOptions(fixture)[0];
+
+ // Sorting is disabled, so CDK itself has no target index to offer — dropping outside every
+ // known list has to resolve to a no-op rather than to CDK's stale starting index.
+ emitCdkDrop(fixture, option);
+
+ expect(fixture.componentInstance.dropped!.currentIndex).toBe(0);
+ expect(getLabels(fixture)).toEqual(['Item 0', 'Item 1', 'Item 2', 'Item 3']);
+ });
+ });
+
+ describe('drop indicator', () => {
+ const getIndicator = (fixture: ComponentFixture) =>
+ (fixture.nativeElement as HTMLElement).querySelector('.kbq-list-selection__drop-indicator');
+
+ const getIndicatorOffset = (fixture: ComponentFixture) =>
+ getIndicator(fixture)!.style.getPropertyValue('--kbq-list-drop-indicator-offset');
+
+ it('should not be rendered until a drag hovers the list', () => {
+ expect(getIndicator(setup(SelectionListWithDragAndDrop))).toBeNull();
+ });
+
+ it('should sit on the gap the pointer is closest to', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+ const option = getOptions(fixture)[0];
+
+ stubVerticalLayout(fixture);
+ // Above every midpoint: the option would land before the first one.
+ list.onOptionDragMoved(option, { x: 50, y: 5 });
+ fixture.detectChanges();
+
+ expect(getIndicatorOffset(fixture)).toBe('20px');
+
+ // Past the last midpoint: the option would land at the very end.
+ list.onOptionDragMoved(option, { x: 50, y: 95 });
+ fixture.detectChanges();
+
+ expect(getIndicatorOffset(fixture)).toBe('80px');
+ });
+
+ it('should disappear once the option has been dropped', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+ const option = getOptions(fixture)[0];
+
+ stubVerticalLayout(fixture);
+ list.onOptionDragMoved(option, { x: 50, y: 55 });
+ fixture.detectChanges();
+
+ expect(getIndicator(fixture)).not.toBeNull();
+
+ emitCdkDrop(fixture, option);
+
+ expect(getIndicator(fixture)).toBeNull();
+ });
+
+ it('should follow the pointer into the connected list and leave the source', () => {
+ const fixture = setup(ConnectedSelectionLists);
+ const [sourceElement, targetElement] = fixture.debugElement
+ .queryAll(By.directive(KbqListSelection))
+ .map(({ nativeElement }) => nativeElement as HTMLElement);
+ const source = fixture.debugElement.queryAll(By.directive(KbqListSelection))[0]
+ .componentInstance as KbqListSelection;
+ const option = getOptions(fixture)[0];
+
+ stubVerticalLayout(fixture);
+ // `stubVerticalLayout` stacks both lists on the same box, so separate them along x and aim
+ // the pointer at the second one.
+ jest.spyOn(targetElement, 'getBoundingClientRect').mockReturnValue({
+ top: 0,
+ bottom: 1000,
+ left: 200,
+ right: 300
+ } as DOMRect);
+
+ source.onOptionDragMoved(option, { x: 250, y: 5 });
+ fixture.detectChanges();
+
+ const indicators = (fixture.nativeElement as HTMLElement).querySelectorAll(
+ '.kbq-list-selection__drop-indicator'
+ );
+
+ expect(indicators.length).toBe(1);
+ expect(targetElement.contains(indicators[0])).toBe(true);
+ expect(sourceElement.querySelector('.kbq-list-selection__drop-indicator')).toBeNull();
+ });
+ });
+
+ describe('keyboard reordering', () => {
+ it('should move the active option down on ALT + DOWN_ARROW', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, DOWN_ARROW);
+ fixture.detectChanges();
+
+ expect(getLabels(fixture)).toEqual(['Item 1', 'Item 0', 'Item 2', 'Item 3']);
+ });
+
+ it('should move the active option up on ALT + UP_ARROW', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ list.keyManager.setActiveItem(2);
+ altKeydown(list, UP_ARROW);
+ fixture.detectChanges();
+
+ expect(getLabels(fixture)).toEqual(['Item 0', 'Item 2', 'Item 1', 'Item 3']);
+ });
+
+ it('should not move past the edges of the list', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, UP_ARROW);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.dropped).toBeNull();
+ expect(getLabels(fixture)).toEqual(['Item 0', 'Item 1', 'Item 2', 'Item 3']);
+ });
+
+ it('should ignore ALT + arrow while not draggable', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+
+ fixture.componentInstance.draggable.set(false);
+ fixture.detectChanges();
+
+ const list = fixture.componentInstance.list();
+
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, DOWN_ARROW);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.dropped).toBeNull();
+ expect(getLabels(fixture)).toEqual(['Item 0', 'Item 1', 'Item 2', 'Item 3']);
+ });
+
+ it('should reorder without changing the selection', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, DOWN_ARROW);
+ fixture.detectChanges();
+
+ expect(list.selectionModel.selected).toEqual([]);
+ });
+
+ it('should announce the new position once the move has been applied', fakeAsync(() => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, DOWN_ARROW);
+ fixture.detectChanges();
+ flush();
+
+ const liveRegion = fixture.nativeElement.querySelector('[aria-live="polite"]');
+
+ expect(liveRegion.textContent.trim()).toBe('Item 0, позиция 2 из 4');
+ }));
+
+ it('should not announce a move the consumer has not applied', fakeAsync(() => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ fixture.componentInstance.applyMove = false;
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, DOWN_ARROW);
+ fixture.detectChanges();
+ flush();
+
+ expect(fixture.nativeElement.querySelector('[aria-live="polite"]').textContent.trim()).toBe('');
+ }));
+
+ it('should move the active option into the connected list on ALT + RIGHT_ARROW', () => {
+ const fixture = setup(ConnectedSelectionLists);
+ const [source, target] = fixture.debugElement
+ .queryAll(By.directive(KbqListSelection))
+ .map((list) => list.componentInstance as KbqListSelection);
+
+ source.keyManager.setActiveItem(0);
+ altKeydown(source, RIGHT_ARROW);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.dropped!.previousContainer).toBe(source);
+ expect(fixture.componentInstance.dropped!.container).toBe(target);
+ expect(fixture.componentInstance.leftItems()).toEqual(['left 1']);
+ expect(fixture.componentInstance.rightItems()).toEqual(['right 0', 'left 0']);
+ });
+
+ it('should move the active option into the connected list on ALT + LEFT_ARROW', () => {
+ const fixture = setup(ConnectedSelectionLists);
+ const [target, source] = fixture.debugElement
+ .queryAll(By.directive(KbqListSelection))
+ .map((list) => list.componentInstance as KbqListSelection);
+
+ source.keyManager.setActiveItem(0);
+ altKeydown(source, LEFT_ARROW);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.dropped!.previousContainer).toBe(source);
+ expect(fixture.componentInstance.dropped!.container).toBe(target);
+ expect(fixture.componentInstance.rightItems()).toEqual([]);
+ expect(fixture.componentInstance.leftItems()).toEqual(['left 0', 'left 1', 'right 0']);
+ });
+
+ it('should ignore ALT + RIGHT_ARROW without a connected list', () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, RIGHT_ARROW);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.dropped).toBeNull();
+ });
+
+ it('should not reach a list connected by id', () => {
+ const fixture = setup(IdConnectedSelectionLists);
+ const source = fixture.debugElement.queryAll(By.directive(KbqListSelection))[0]
+ .componentInstance as KbqListSelection;
+
+ source.keyManager.setActiveItem(0);
+ altKeydown(source, RIGHT_ARROW);
+ fixture.detectChanges();
+
+ // An id cannot be resolved back to a list instance, so the transfer has no target.
+ expect(fixture.componentInstance.dropped).toBeNull();
+ expect(fixture.componentInstance.sourceItems()).toEqual(['source 0', 'source 1']);
+ });
+
+ it('should keep the shift-range anchor in sync after a reorder', fakeAsync(() => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ // Anchor the range on the first option, then move it down: the anchor must follow the option,
+ // otherwise the range below spans the wrong items.
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, DOWN_ARROW);
+ fixture.detectChanges();
+ flush();
+
+ expect(list.keyManager.previousActiveItemIndex).toBe(list.keyManager.activeItemIndex);
+ }));
+ });
+
+ describe('accessibility (axe)', () => {
+ afterEach(() => {
+ if (document.body.contains(fixtureElement!)) {
+ document.body.removeChild(fixtureElement!);
+ }
+ });
+
+ let fixtureElement: HTMLElement | null = null;
+
+ it('has no axe violations while draggable', async () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+
+ fixtureElement = fixture.nativeElement;
+ document.body.appendChild(fixture.nativeElement);
+
+ expect(await axe(fixture.nativeElement)).toHaveNoViolations();
+ });
+
+ it('has no axe violations once the live region carries an announcement', async () => {
+ const fixture = setup(SelectionListWithDragAndDrop);
+ const list = fixture.componentInstance.list();
+
+ fixtureElement = fixture.nativeElement;
+ document.body.appendChild(fixture.nativeElement);
+
+ list.keyManager.setActiveItem(0);
+ altKeydown(list, DOWN_ARROW);
+ fixture.detectChanges();
+ // The announcement is filled in on the tick after the move has been applied.
+ await new Promise((resolve) => setTimeout(resolve));
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('[aria-live="polite"]').textContent.trim()).not.toBe('');
+ expect(await axe(fixture.nativeElement)).toHaveNoViolations();
+ });
+
+ const getShortcuts = (fixture: ComponentFixture) =>
+ (fixture.nativeElement as HTMLElement).querySelector('kbq-list-option')!.getAttribute('aria-keyshortcuts');
+
+ it('advertises the reordering shortcuts on a draggable option', () => {
+ expect(getShortcuts(setup(SelectionListWithDragAndDrop))).toBe('Alt+ArrowUp Alt+ArrowDown');
+ });
+
+ it('advertises the transfer shortcuts once a connected list can be reached', () => {
+ expect(getShortcuts(setup(ConnectedSelectionLists))).toBe(
+ 'Alt+ArrowUp Alt+ArrowDown Alt+ArrowLeft Alt+ArrowRight'
+ );
+ });
+
+ it('advertises no shortcuts while the list is not draggable', () => {
+ expect(getShortcuts(setup(SelectionListWithListOptions))).toBeNull();
+ });
+ });
+
+ describe('unsupported containers', () => {
+ let warn: jest.SpyInstance;
+
+ beforeEach(() => {
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => warn.mockRestore());
+
+ it('should warn about an optgroup once the list becomes draggable', () => {
+ const fixture = setup(SelectionListInOptgroup);
+
+ expect(warn).not.toHaveBeenCalled();
+
+ // Enabled after init: the warning has to survive a late toggle, not only the first render.
+ fixture.componentInstance.draggable.set(true);
+ fixture.detectChanges();
+
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('kbq-optgroup'));
+ });
+
+ it('should warn about an optgroup only once', () => {
+ const fixture = setup(SelectionListInOptgroup);
+
+ fixture.componentInstance.draggable.set(true);
+ fixture.detectChanges();
+ fixture.componentInstance.draggable.set(false);
+ fixture.detectChanges();
+ fixture.componentInstance.draggable.set(true);
+ fixture.detectChanges();
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not warn about a plain draggable list', () => {
+ setup(SelectionListWithDragAndDrop);
+
+ expect(warn).not.toHaveBeenCalled();
+ });
+ });
+});
+
@Component({
imports: [
KbqListModule,
@@ -1574,3 +2122,138 @@ class SelectionListWithOnCopyHandler {
this.copyEvent = event;
}
}
+
+@Component({
+ imports: [KbqListModule],
+ template: `
+
+ @for (item of items(); track item) {
+ {{ item }}
+ }
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+class SelectionListWithDragAndDrop {
+ readonly list = viewChild.required(KbqListSelection);
+ readonly items = signal(Array.from({ length: 4 }, (_, i) => `Item ${i}`));
+
+ readonly draggable = signal(true);
+ readonly disabled = signal(false);
+ readonly disabledItem = signal(null);
+ /** Lets a test assert what happens when the consumer ignores the event. */
+ applyMove = true;
+ dropped: KbqListSelectionDroppedEvent | null = null;
+
+ handleDropped(event: KbqListSelectionDroppedEvent): void {
+ this.dropped = event;
+
+ if (!this.applyMove) {
+ return;
+ }
+
+ const items = [...this.items()];
+
+ moveItemInArray(items, event.previousIndex, event.currentIndex);
+ this.items.set(items);
+ }
+}
+
+@Component({
+ imports: [KbqListModule],
+ template: `
+
+ @for (item of leftItems(); track item) {
+ {{ item }}
+ }
+
+
+ @for (item of rightItems(); track item) {
+ {{ item }}
+ }
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+class ConnectedSelectionLists {
+ readonly leftItems = signal(['left 0', 'left 1']);
+ readonly rightItems = signal(['right 0']);
+
+ dropped: KbqListSelectionDroppedEvent | null = null;
+
+ handleDropped(event: KbqListSelectionDroppedEvent): void {
+ this.dropped = event;
+
+ const fromLeft = this.leftItems().includes(event.option.value);
+ const source = fromLeft ? this.leftItems : this.rightItems;
+
+ if (event.previousContainer === event.container) {
+ const items = [...source()];
+
+ moveItemInArray(items, event.previousIndex, event.currentIndex);
+ source.set(items);
+
+ return;
+ }
+
+ const target = fromLeft ? this.rightItems : this.leftItems;
+ const from = [...source()];
+ const to = [...target()];
+
+ transferArrayItem(from, to, event.previousIndex, event.currentIndex);
+
+ source.set(from);
+ target.set(to);
+ }
+}
+
+@Component({
+ imports: [KbqListModule],
+ template: `
+
+ @for (item of sourceItems(); track item) {
+ {{ item }}
+ }
+
+
+ target 0
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+class IdConnectedSelectionLists {
+ readonly sourceItems = signal(['source 0', 'source 1']);
+
+ dropped: KbqListSelectionDroppedEvent | null = null;
+}
+
+@Component({
+ imports: [KbqListModule, KbqOptionModule],
+ template: `
+
+
+ Item 0
+ Item 1
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+class SelectionListInOptgroup {
+ readonly draggable = signal(false);
+}
diff --git a/packages/components/list/list-selection.component.ts b/packages/components/list/list-selection.component.ts
index 931470a041..67cf0d4f33 100644
--- a/packages/components/list/list-selection.component.ts
+++ b/packages/components/list/list-selection.component.ts
@@ -2,6 +2,7 @@
import { Clipboard } from '@angular/cdk/clipboard';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { SelectionModel } from '@angular/cdk/collections';
+import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { Platform } from '@angular/cdk/platform';
import {
AfterContentInit,
@@ -15,6 +16,7 @@ import {
ContentChildren,
DestroyRef,
Directive,
+ effect,
ElementRef,
EventEmitter,
forwardRef,
@@ -22,12 +24,14 @@ import {
inject,
Input,
input,
+ isDevMode,
NgZone,
OnDestroy,
OnInit,
Output,
output,
QueryList,
+ signal,
ViewChild,
viewChild,
ViewEncapsulation
@@ -49,6 +53,7 @@ import {
KBQ_TITLE_TEXT_REF,
KbqActionContainer,
kbqFocusOptionActionOnTab,
+ kbqInjectA11yLocaleConfiguration,
KbqOptgroup,
KbqOptionActionComponent,
KbqPseudoCheckbox,
@@ -107,10 +112,48 @@ export class KbqListCopyEvent {
) {}
}
+/**
+ * Data attached to the underlying `CdkDrag` while a list option is being dragged. Kept unexported:
+ * `dropped` already hands the option over, so nothing outside this file has to read `CdkDrag.data`.
+ */
+type KbqListOptionDragData = { option: KbqListOption };
+
+/** Event emitted when an option changes its position by dragging or by keyboard. */
+export type KbqListSelectionDroppedEvent = Pick, 'previousIndex' | 'currentIndex'> & {
+ /** Option that has been moved. */
+ option: KbqListOption;
+ /** List the option has been moved into. */
+ container: KbqListSelection;
+ /** List the option has been taken from. Equal to `container` when reordering within a single list. */
+ previousContainer: KbqListSelection;
+ /** Pointer event for dragging, keyboard event for `Alt` + arrow reordering. */
+ event: MouseEvent | TouchEvent | KeyboardEvent;
+};
+
+/**
+ * Whether `Alt` is the only modifier held. `hasModifierKey` matches any of the listed modifiers,
+ * which would also swallow combinations already bound to selection (`Ctrl`/`Shift` + arrow).
+ */
+const isAltOnly = (event: KeyboardEvent): boolean =>
+ event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey;
+
@Component({
selector: 'kbq-list-selection',
template: `
+ @if (dropIndicatorOffset() !== null) {
+
+
+ }
+
+ {{ announcement() }}
`,
styleUrls: ['./list.scss', 'list-tokens.scss'],
providers: [KBQ_SELECTION_LIST_VALUE_ACCESSOR],
@@ -118,6 +161,8 @@ export class KbqListCopyEvent {
encapsulation: ViewEncapsulation.None,
host: {
class: 'kbq-list-selection',
+ '[class.kbq-list-selection_draggable]': 'draggable',
+ '[class.kbq-list-selection_horizontal]': 'horizontal()',
'[attr.tabindex]': 'tabIndex',
'[attr.disabled]': 'disabled || null',
'(keydown)': 'onKeyDown($event)',
@@ -125,6 +170,9 @@ export class KbqListCopyEvent {
'(blur)': 'blur()',
'(window:resize)': 'updateScrollSize()'
},
+ // `id` is exposed so that a consumer-set id survives `CdkDropList`'s own `[attr.id]` host binding
+ // and can be used as a `connectedTo` reference.
+ hostDirectives: [{ directive: CdkDropList, inputs: ['id'] }],
exportAs: 'kbqListSelection',
preserveWhitespaces: false
})
@@ -168,6 +216,44 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
private _noUnselectLast: boolean = true;
+ /**
+ * Whether options can be reordered by dragging them or by pressing `Alt` + arrow keys.
+ * Reordering never mutates the data — handle the `dropped` event and move the item yourself.
+ */
+ @Input({ transform: booleanAttribute })
+ get draggable(): boolean {
+ return this._draggable && !this.disabled;
+ }
+
+ set draggable(value: boolean) {
+ this._draggable = value;
+ this.syncDraggableState();
+ }
+
+ private _draggable: boolean = false;
+
+ /**
+ * Lists that options of this list can be moved into. Accepts `KbqListSelection` instances or the
+ * `id` of another list. `cdkDropListGroup` on a common ancestor connects lists automatically.
+ */
+ readonly connectedTo = input([]);
+
+ /** Emits when an option changes its position by dragging or by `Alt` + arrow keys. */
+ readonly dropped = output();
+
+ /**
+ * Reordering shortcuts advertised on a draggable option, so that the keyboard alternative to
+ * dragging is discoverable without the documentation. Transfer shortcuts are left out when no
+ * connected list can be reached by keyboard.
+ *
+ * @docs-private
+ */
+ get ariaKeyShortcuts(): string {
+ const reorder = 'Alt+ArrowUp Alt+ArrowDown';
+
+ return this.getAdjacentList('next') ? `${reorder} Alt+ArrowLeft Alt+ArrowRight` : reorder;
+ }
+
/** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */
readonly selectAllToggle = input(false, { transform: booleanAttribute });
@@ -203,6 +289,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
set disabled(value: boolean) {
if (value !== this.disabled) {
this._disabled = value;
+ this.syncDraggableState();
}
}
@@ -238,11 +325,29 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
private readonly destroyRef = inject(DestroyRef);
private readonly platform = inject(Platform);
+ private readonly dropList = inject>(CdkDropList, { host: true });
+ private readonly a11yLocale = kbqInjectA11yLocaleConfiguration();
+
+ /** Message read out by assistive tech after an option has been reordered. */
+ protected readonly announcement = signal('');
+
+ /**
+ * Distance from the list's content box to the gap the dragged option would land in, or `null`
+ * while no drag is hovering this list. Drives the insertion indicator.
+ */
+ protected readonly dropIndicatorOffset = signal(null);
+
+ /** Gap the indicator currently marks, used as `currentIndex` when the drag is dropped here. */
+ private dropIndex: number | null = null;
private optionFocusSubscription: Subscription | null;
private optionBlurSubscription: Subscription | null;
+ private pendingMoveSubscription: Subscription | null;
+
+ private hasWarnedOnDragContainer = false;
+
constructor() {
const multiple = inject(new HostAttributeToken('multiple'), { optional: true });
@@ -258,6 +363,8 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
}
this.selectionModel = new SelectionModel(this.multiple);
+
+ this.setupDropListInitialProperties();
}
ngAfterContentInit(): void {
@@ -298,6 +405,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
if (!this.platform.isBrowser) return;
+ this.warnOnUnsupportedDragContainer();
this.updateScrollSize();
}
@@ -321,6 +429,25 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
}
}
+ /**
+ * Moves keyboard focus to the option matching `value` through `compareWith`. Use it to restore
+ * focus after a `dropped` event has moved an option into another list, where the original option
+ * instance no longer exists.
+ *
+ * @returns whether a matching option was found.
+ */
+ focusOptionByValue(value: unknown): boolean {
+ const index = this.options.toArray().findIndex((option) => this.compareWith()(option.value, value));
+
+ if (index === -1) {
+ return false;
+ }
+
+ this.keyManager.setActiveItem(index);
+
+ return true;
+ }
+
blur() {
if (!this.hasFocusedOption()) {
this.keyManager.setActiveItem(-1);
@@ -392,12 +519,15 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
const options = this.options.toArray();
let fromIndex = this.keyManager.previousActiveItemIndex;
let toIndex = (this.keyManager.previousActiveItemIndex = this.keyManager.activeItemIndex);
- const selectedOptionState = options[fromIndex].selected;
- if (toIndex === fromIndex) {
+ // `previousActiveItemIndex` is -1 until the list has been navigated, so a shift+click without
+ // prior keyboard navigation has no range to extend.
+ if (toIndex === fromIndex || !this.isValidIndex(fromIndex)) {
return;
}
+ const selectedOptionState = options[fromIndex].selected;
+
if (fromIndex > toIndex) {
[fromIndex, toIndex] = [toIndex, fromIndex];
}
@@ -497,6 +627,12 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
event.preventDefault();
}
+ if (this.draggable && isAltOnly(event) && [UP_ARROW, DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW].includes(keyCode)) {
+ this.moveActiveOptionByKey(event);
+
+ return;
+ }
+
if (this.multiple && isSelectAll(event)) {
this.selectAllHandler(event, this);
@@ -607,7 +743,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
// Returns the option with the specified value.
private getOptionByValue(value: string): KbqListOption | undefined {
- return this.options.find((option) => option.value === value);
+ return this.options.find((option) => this.compareWith()(option.value, value));
}
// Sets the selected options based on the specified values.
@@ -634,6 +770,316 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
return this.options.toArray().indexOf(option);
}
+ private setupDropListInitialProperties(): void {
+ // Lets the `dropped` handler map a `CdkDropList` back to the list that owns it.
+ this.dropList.data = this;
+ // The drop position is shown by an insertion indicator instead of by opening a gap, so CDK must
+ // not sort: the options stay put and the dragged one keeps its slot as a faded placeholder.
+ // Both flags are re-read from the directive on every drag start, so assigning once is enough.
+ this.dropList.sortingDisabled = true;
+ // Without an anchor the placeholder follows the option into the connected list, leaving the
+ // origin list with a hole instead of the faded row.
+ this.dropList.hasAnchor = true;
+ this.syncDraggableState();
+
+ effect(() => {
+ this.dropList.orientation = this.horizontal() ? 'horizontal' : 'vertical';
+ // `CdkDropList` resolves its siblings on every drag start, so a late assignment is picked up.
+ this.dropList.connectedTo = this.resolveConnectedDropLists();
+ });
+
+ // `enter()` is not gated on `sortingDisabled`, so the placeholder follows the option into
+ // whichever list it is dragged over and pushes that list's options apart. Only the list the
+ // drag started in may show it — there it is the faded row left behind. Toggled imperatively
+ // because the placeholder is already in the DOM by the time this runs, and waiting for change
+ // detection would let the shifted layout paint for a frame.
+ this.dropList.entered.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ item }) => {
+ item.getPlaceholderElement().classList.toggle(
+ 'kbq-list-option_foreign-placeholder',
+ item.dropContainer.data !== this
+ );
+ });
+
+ this.dropList.dropped
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(({ previousIndex, previousContainer, container, item, event }) => {
+ const { option }: KbqListOptionDragData = item.data;
+ const target: KbqListSelection = container.data;
+ // Sorting is disabled, so CDK reports the untouched starting index. The gap the indicator
+ // marked is the real target; without one the pointer was outside every list we know, which
+ // resolves to a move that changes nothing.
+ const currentIndex = target.dropIndex ?? previousIndex;
+
+ this.clearDropIndicators(option);
+
+ this.emitDropped({
+ option,
+ previousIndex,
+ currentIndex,
+ previousContainer: previousContainer.data,
+ container: target,
+ event
+ });
+ });
+ }
+
+ /**
+ * Both containers break the index space `dropped` reports: `CdkDropList` numbers only the options
+ * it has rendered, and an option inside a group is numbered within that group. Either way the
+ * indices do not address the consumer's backing array and the move silently lands on the wrong
+ * item, so warn instead of letting it pass unnoticed.
+ */
+ private warnOnUnsupportedDragContainer(): void {
+ // Runs on every `draggable`/`disabled` change as well as on init, so it has to tolerate being
+ // called before the content children exist and to stay quiet once it has had its say.
+ if (this.hasWarnedOnDragContainer || !isDevMode() || !this.draggable || !this.platform.isBrowser) {
+ return;
+ }
+
+ const insideVirtualScroll = !!this.elementRef.nativeElement.querySelector('cdk-virtual-scroll-viewport');
+ const insideOptgroup = !!this.options?.some((option) => !!option.group);
+
+ if (insideVirtualScroll) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'KbqListSelection: `draggable` is not supported inside `cdk-virtual-scroll-viewport`. The ' +
+ 'indices reported by `dropped` count only the rendered options.'
+ );
+ }
+
+ if (insideOptgroup) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'KbqListSelection: `draggable` is not supported inside `kbq-optgroup`. The indices reported ' +
+ 'by `dropped` are relative to the group, not to the list.'
+ );
+ }
+
+ this.hasWarnedOnDragContainer = insideVirtualScroll || insideOptgroup;
+ }
+
+ /** Keeps the underlying CDK directives in sync with the resolved `draggable` state. */
+ private syncDraggableState(): void {
+ this.dropList.disabled = !this.draggable;
+ this.options?.forEach((option) => option.syncDraggableState());
+ this.warnOnUnsupportedDragContainer();
+ this.changeDetectorRef.markForCheck();
+ }
+
+ private normalizeConnectedTo(): readonly (KbqListSelection | string)[] {
+ const connectedTo = this.connectedTo();
+
+ return Array.isArray(connectedTo) ? connectedTo : [connectedTo as KbqListSelection | string];
+ }
+
+ private resolveConnectedLists(): KbqListSelection[] {
+ return this.normalizeConnectedTo().filter((item): item is KbqListSelection => typeof item !== 'string');
+ }
+
+ private resolveConnectedDropLists(): (CdkDropList | string)[] {
+ return this.normalizeConnectedTo().map((item) => (typeof item === 'string' ? item : item.dropList));
+ }
+
+ /**
+ * Tracks the dragged option and paints the insertion indicator in whichever list the pointer is
+ * over. Lists connected by `id` alone cannot be resolved to an instance, so they never light up —
+ * the same limitation that keeps them out of reach of the keyboard.
+ *
+ * @docs-private
+ */
+ onOptionDragMoved(option: KbqListOption, pointer: { x: number; y: number }): void {
+ const lists = [this, ...this.resolveConnectedLists()];
+ const hovered = lists.find((list) => list.containsPoint(pointer));
+
+ for (const list of lists) {
+ if (list !== hovered) {
+ list.clearDropIndicator();
+ }
+ }
+
+ hovered?.showDropIndicator(option, pointer);
+ }
+
+ /**
+ * Drops the indicator in every list that could be showing one for this drag.
+ *
+ * @docs-private
+ */
+ clearDropIndicators(option: KbqListOption): void {
+ for (const list of [this, ...this.resolveConnectedLists(), option.listSelection]) {
+ list.clearDropIndicator();
+ }
+ }
+
+ private clearDropIndicator(): void {
+ this.dropIndex = null;
+ this.dropIndicatorOffset.set(null);
+ }
+
+ private containsPoint({ x, y }: { x: number; y: number }): boolean {
+ if (!this.draggable) {
+ return false;
+ }
+
+ const { top, right, bottom, left } = this.elementRef.nativeElement.getBoundingClientRect();
+
+ return x >= left && x <= right && y >= top && y <= bottom;
+ }
+
+ /**
+ * Resolves the gap the pointer sits in and positions the indicator on it. Geometry is read on every
+ * move rather than cached at drag start: nothing in the list reflows while dragging, but CDK
+ * auto-scrolls near the edges, which would silently invalidate a cache.
+ */
+ private showDropIndicator(dragged: KbqListOption, pointer: { x: number; y: number }): void {
+ const horizontal = this.horizontal();
+ // The dragged option is left out on purpose: CDK pulls its host element out of the flow, and
+ // excluding it makes the resulting gap index directly usable as `currentIndex` — that is exactly
+ // the index space `moveItemInArray` and `transferArrayItem` expect.
+ const rects = this.options
+ .filter((option) => option !== dragged)
+ .map((option) => option.getHostElement().getBoundingClientRect());
+
+ const startOf = (rect: DOMRect) => (horizontal ? rect.left : rect.top);
+ const endOf = (rect: DOMRect) => (horizontal ? rect.right : rect.bottom);
+ const midpointOf = (rect: DOMRect) => (startOf(rect) + endOf(rect)) / 2;
+
+ const position = horizontal ? pointer.x : pointer.y;
+ const index = rects.filter((rect) => position >= midpointOf(rect)).length;
+
+ const container = this.elementRef.nativeElement.getBoundingClientRect();
+ const scrolled = horizontal
+ ? this.elementRef.nativeElement.scrollLeft
+ : this.elementRef.nativeElement.scrollTop;
+
+ // The gap sits before the first option, or right after the one that precedes it. An empty list
+ // has no options to measure, so the indicator goes to the top of its content box.
+ let boundary = startOf(container);
+
+ if (rects.length) {
+ boundary = index === 0 ? startOf(rects[0]) : endOf(rects[index - 1]);
+ }
+
+ this.dropIndex = index;
+ this.dropIndicatorOffset.set(boundary - startOf(container) + scrolled);
+ this.changeDetectorRef.markForCheck();
+ }
+
+ /**
+ * Moves the active option one position with `Alt` + up/down, or into the previous/next connected
+ * list with `Alt` + left/right.
+ */
+ private moveActiveOptionByKey(event: KeyboardEvent): void {
+ const option = this.keyManager.activeItem;
+
+ if (!option || option.disabled) {
+ return;
+ }
+
+ const previousIndex = this.getOptionIndex(option);
+
+ if ([UP_ARROW, DOWN_ARROW].includes(event.keyCode)) {
+ const currentIndex = previousIndex + (event.keyCode === UP_ARROW ? -1 : 1);
+
+ if (!this.isValidIndex(currentIndex)) {
+ return;
+ }
+
+ this.emitDropped({
+ option,
+ previousIndex,
+ currentIndex,
+ previousContainer: this,
+ container: this,
+ event
+ });
+
+ return;
+ }
+
+ const container = this.getAdjacentList(event.keyCode === LEFT_ARROW ? 'previous' : 'next');
+
+ if (!container) {
+ return;
+ }
+
+ this.emitDropped({
+ option,
+ previousIndex,
+ currentIndex: container.options.length,
+ previousContainer: this,
+ container,
+ event
+ });
+ }
+
+ /**
+ * Target of a keyboard transfer: the first connected list for `Alt` + right, the last one for
+ * `Alt` + left. Lists connected by `id` cannot be resolved and are therefore keyboard-unreachable.
+ */
+ private getAdjacentList(direction: 'previous' | 'next'): KbqListSelection | undefined {
+ const lists = this.resolveConnectedLists().filter((list) => list.draggable);
+
+ return direction === 'next' ? lists.at(0) : lists.at(-1);
+ }
+
+ private emitDropped(event: KbqListSelectionDroppedEvent): void {
+ this.dropped.emit(event);
+
+ // Positional state does not survive a reorder: the key manager re-syncs `activeItemIndex` from
+ // `options.changes`, but `previousActiveItemIndex` (the anchor of shift-range selection) does not.
+ this.keyManager.previousActiveItemIndex = this.keyManager.activeItemIndex;
+
+ this.announceMove(event.option, event.container);
+ }
+
+ /**
+ * Announces the option's new position, but only once the consumer has actually applied the move —
+ * the list never reorders its own content, so nothing is announced if `dropped` is left unhandled.
+ */
+ private announceMove(option: KbqListOption, container: KbqListSelection): void {
+ const { value } = option;
+ const label = option.getLabel();
+
+ // Tracked on the list the move is awaited on, so that a move into a connected list does not
+ // cancel one still pending here. A move that is never applied leaves its subscription pending,
+ // so drop the previous one first.
+ container.pendingMoveSubscription?.unsubscribe();
+
+ container.pendingMoveSubscription = container.options.changes
+ .pipe(take(1), takeUntilDestroyed(container.destroyRef))
+ .subscribe(() => {
+ const options = container.options.toArray();
+ // Reordering within a list keeps the instance; moving between lists recreates it.
+ const index = options.includes(option)
+ ? options.indexOf(option)
+ : options.findIndex((item) => container.compareWith()(item.value, value));
+
+ if (index === -1) {
+ return;
+ }
+
+ const values: Record = {
+ label,
+ index: `${index + 1}`,
+ total: `${options.length}`
+ };
+ // Single pass over the template: a label that itself contains `{{ index }}` must not be
+ // rescanned and consume the substitution meant for the real placeholder.
+ const message = container
+ .a11yLocale()
+ .listOptionMoved.replace(/{{ (label|index|total) }}/g, (_, name: string) => values[name]);
+
+ // Empty then re-fill on the next tick so an identical consecutive message still changes the
+ // live-region text node and is re-announced (a same-string `set` would be an `Object.is`
+ // no-op that assistive tech never picks up).
+ container.announcement.set('');
+ setTimeout(() => container.announcement.set(message));
+
+ container.keyManager.setActiveItem(index);
+ });
+ }
+
// View to model callback that should be called whenever the selected options change.
private onChange: (value: any) => void = (_: any) => {};
@@ -728,16 +1174,19 @@ export class KbqListOptionCaption {}
class: 'kbq-list-option',
'[class.kbq-selected]': 'selected',
'[class.kbq-list-option_multiple]': 'listSelection.multiple',
+ '[class.kbq-list-option_draggable]': 'draggable',
'[class.kbq-disabled]': 'disabled',
'[class.kbq-focused]': 'hasFocus',
'[class.kbq-action-button-focused]': 'actionButton()?.active',
'[attr.tabindex]': 'tabIndex',
'[attr.disabled]': 'disabled || null',
+ '[attr.aria-keyshortcuts]': 'draggable ? listSelection.ariaKeyShortcuts : null',
'(focusin)': 'focus()',
'(blur)': 'blur()',
'(click)': 'handleClick($event)',
'(keydown)': 'onKeydown($event)'
},
+ hostDirectives: [CdkDrag],
exportAs: 'kbqListOption',
preserveWhitespaces: false
})
@@ -745,6 +1194,8 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi
private elementRef = inject>(ElementRef);
private changeDetector = inject(ChangeDetectorRef);
private ngZone = inject(NgZone);
+ private readonly drag = inject>(CdkDrag, { host: true });
+ private readonly destroyRef = inject(DestroyRef);
listSelection = inject(KbqListSelection);
readonly group = inject(KbqOptgroup, { optional: true });
hasFocus: boolean = false;
@@ -805,12 +1256,17 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi
set disabled(value: boolean) {
if (value !== this._disabled) {
this._disabled = value;
- this.changeDetector.markForCheck();
+ this.syncDraggableState();
}
}
private _disabled = false;
+ /** Whether this option can be dragged. Driven by the list — options have no `draggable` input. */
+ protected get draggable(): boolean {
+ return this.listSelection.draggable && !this.disabled;
+ }
+
// TODO: Skipped for migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input()
@@ -847,6 +1303,38 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi
return !!this.pseudoCheckbox();
}
+ constructor() {
+ this.syncDraggableState();
+
+ // The whole row is the drag handle, so a touch drag has to lose to a scroll gesture.
+ this.drag.dragStartDelay = { touch: 300, mouse: 0 };
+
+ // Assigned lazily: referencing `this` while the host directive is still being constructed
+ // would capture a half-initialized option.
+ // Nothing is cleared on `ended`: it fires *before* `dropped`, so tearing the indicator down
+ // there would take the resolved target index with it and turn every drop into a no-op.
+ this.drag.started.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
+ this.drag.data = { option: this };
+ this.listSelection.clearDropIndicators(this);
+ });
+
+ // Sorting is disabled, so CDK gives no running feedback about where the option would land —
+ // the owning list derives it from the pointer and paints the insertion indicator itself.
+ this.drag.moved.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ pointerPosition }) => {
+ this.listSelection.onOptionDragMoved(this, pointerPosition);
+ });
+ }
+
+ /**
+ * Keeps the underlying `CdkDrag` in sync with the resolved `draggable` state.
+ *
+ * @docs-private
+ */
+ syncDraggableState(): void {
+ this.drag.disabled = !this.draggable;
+ this.changeDetector.markForCheck();
+ }
+
ngOnInit() {
const list = this.listSelection;
diff --git a/packages/components/list/list-tokens.scss b/packages/components/list/list-tokens.scss
index a56e8cdd62..91f9ac4c09 100644
--- a/packages/components/list/list-tokens.scss
+++ b/packages/components/list/list-tokens.scss
@@ -27,10 +27,16 @@
--kbq-list-states-disabled-icon-color: var(--kbq-states-icon-disabled);
--kbq-list-states-disabled-icon-button-color: var(--kbq-states-icon-disabled);
--kbq-list-states-disabled-caption-color: var(--kbq-states-foreground-disabled);
+ // The dragged clone floats over the page, so unlike an option in place it needs an opaque surface.
+ --kbq-list-states-dragged-container-background: var(--kbq-background-card);
+ --kbq-list-states-dragged-container-shadow: var(--kbq-shadow-popup);
}
.kbq-list,
-.kbq-list-selection {
+.kbq-list-selection,
+// The drag preview is a clone of the option inserted into ``, outside any list, so it has to
+// carry the tokens itself — otherwise every themed color below resolves to nothing.
+.kbq-list-option.cdk-drag-preview {
--kbq-list-size-container-padding-left: var(--kbq-size-m);
--kbq-list-size-container-padding-right: var(--kbq-size-m);
--kbq-list-size-container-padding-vertical: var(--kbq-size-xs);
@@ -46,3 +52,11 @@
/* THEME TOKENS */
@include list-theme-tokens;
}
+
+// Declared here rather than in `list-theme-tokens`, which is also included by the option and dropdown
+// token files: the insertion indicator only ever exists on `kbq-list-selection`. The colour still
+// follows the theme, because the global token it points at does.
+.kbq-list-selection {
+ --kbq-list-size-dragged-indicator-thickness: var(--kbq-size-3xs);
+ --kbq-list-states-dragged-indicator-color: var(--kbq-line-theme);
+}
diff --git a/packages/components/list/list.en.md b/packages/components/list/list.en.md
index e8eeb7d200..d67e6fda61 100644
--- a/packages/components/list/list.en.md
+++ b/packages/components/list/list.en.md
@@ -21,3 +21,42 @@
### Virtual scroll
+
+### Drag and drop
+
+Set the `draggable` property on `kbq-list-selection` to let the user reorder options.
+
+The list does not open a gap while an option is being dragged: the surrounding options stay put, the
+dragged one keeps its place as a faded row, and a line marks the position the option would land in.
+
+The list never changes the data itself — it reports the move through the `dropped` event and you
+apply it, usually with `moveItemInArray` from `@angular/cdk/drag-drop`. Track the options by their
+identity (`track item.id`): with a positional key such as `track $index` the option at a given
+position is kept and rebound to a different value, and an option drops its selection when its value
+changes.
+
+
+
+Options can also be moved into another list. Pass the other `kbq-list-selection` through `connectedTo`
+on both lists, and apply the move with `transferArrayItem`. An option arrives in the target list
+unselected unless the target's own value already contains it.
+
+
+
+Dragging is not supported inside `kbq-optgroup` or `cdk-virtual-scroll-viewport`: the indices reported
+by `dropped` count only the rendered options, or are relative to the group rather than to the list, so
+applying the move to the backing array silently affects the wrong item. Both combinations log a warning
+in development mode.
+
+#### Keyboard
+
+Dragging always has a keyboard equivalent, so the feature stays usable without a pointer.
+
+| Key
| Action |
+| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| Alt + ↑ / ↓ | Move the focused option one position. |
+| Alt + ← / → | Move the focused option into a connected list. |
+
+The new position is announced through a live region. Lists connected by `id` rather than by a
+component reference cannot be reached with the keyboard, and they show no drop indicator — an `id`
+cannot be resolved back to the list instance that would have to draw it.
diff --git a/packages/components/list/list.ru.md b/packages/components/list/list.ru.md
index e8eeb7d200..ef155c8f8f 100644
--- a/packages/components/list/list.ru.md
+++ b/packages/components/list/list.ru.md
@@ -21,3 +21,41 @@
### Virtual scroll
+
+### Перестановка и сортировка
+
+Для включения возможности перестановки опций необходимо установить свойство `draggable` для `kbq-list-selection`.
+
+Во время перетаскивания список не раздвигается: соседние опции остаются на местах, перетаскиваемая
+сохраняет своё место в виде приглушённой строки, а позицию, куда она встанет, показывает линия.
+
+Список никогда не изменяет данные сам — он сообщает о перемещении через событие `dropped`, а применяете
+его вы, обычно с помощью `moveItemInArray` из `@angular/cdk/drag-drop`. Отслеживайте опции по
+идентификатору (`track item.id`): при позиционном ключе вроде `track $index` опция на своём месте
+сохраняется и получает новое значение, а смена значения сбрасывает её выбор.
+
+
+
+Опции можно переносить и в другой список. Передайте соседний `kbq-list-selection` в `connectedTo` у
+обоих списков и примените перемещение через `transferArrayItem`. В списке-приёмнике опция появляется
+невыбранной, если её значения ещё нет в его модели.
+
+
+
+Перетаскивание не поддерживается внутри `kbq-optgroup` и `cdk-virtual-scroll-viewport`: индексы в
+событии `dropped` учитывают только отрисованные опции либо отсчитываются от группы, а не от списка,
+поэтому применение перемещения к массиву данных незаметно затронет не тот элемент. В режиме разработки
+оба случая выводят предупреждение.
+
+#### Навигация с клавиатуры
+
+У перетаскивания всегда есть клавиатурный эквивалент, поэтому функция остаётся доступной без указателя.
+
+| Клавиша
| Действие |
+| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| Alt + ↑ / ↓ | Переместить опцию в фокусе на одну позицию. |
+| Alt + ← / → | Переместить опцию в фокусе в связанный список. |
+
+Новая позиция объявляется через live-регион. До списков, связанных по `id`, а не по ссылке на
+компонент, с клавиатуры добраться нельзя, и индикатор в них не показывается: по `id` нельзя получить
+экземпляр списка, который должен его нарисовать.
diff --git a/packages/components/list/list.scss b/packages/components/list/list.scss
index 92e8f4af4d..735e9f46f8 100644
--- a/packages/components/list/list.scss
+++ b/packages/components/list/list.scss
@@ -25,6 +25,11 @@
outline: none;
}
+// Positioning context for the drop indicator, which is the list's own last child.
+.kbq-list-selection {
+ position: relative;
+}
+
.kbq-list-item,
.kbq-list-option {
@include vendor-prefixes.user-select(none);
@@ -37,5 +42,66 @@
}
}
+.kbq-list-option {
+ &.kbq-list-option_draggable {
+ cursor: grab;
+ }
+
+ // The row the drag started from keeps its slot. `cdk-drag-anchor` is the clone CDK leaves behind
+ // once the option has been dragged into a connected list.
+ &.cdk-drag-placeholder,
+ &.cdk-drag-anchor {
+ opacity: var(--kbq-opacity-disabled);
+ }
+
+ // Hidden while the placeholder sits in a list the option was not dragged from, so that list keeps
+ // its layout. Toggled from `KbqListSelection` on `cdkDropListEntered`.
+ &.kbq-list-option_foreign-placeholder {
+ display: none;
+ }
+
+ // No `.cdk-drag-animating` transition on purpose. CDK animates the preview onto the placeholder,
+ // which — with sorting disabled — never leaves the slot the drag started from, so the preview
+ // would fly back to the origin and read as a rejected drop. With no transition declared the
+ // duration is 0 and CDK disposes of the preview without animating it anywhere.
+}
+
+// Marks the gap the dragged option would land in. Sorting is disabled, so the list never opens a gap
+// of its own and this line is the only feedback about the target position.
+.kbq-list-selection__drop-indicator {
+ position: absolute;
+ background: var(--kbq-list-states-dragged-indicator-color);
+ pointer-events: none;
+}
+
+.kbq-list-selection:not(.kbq-list-selection_horizontal) .kbq-list-selection__drop-indicator {
+ left: 0;
+ right: 0;
+ // The line straddles the gap instead of sitting below it, so it reads as "between these two rows".
+ top: calc(var(--kbq-list-drop-indicator-offset, 0px) - var(--kbq-list-size-dragged-indicator-thickness) / 2);
+ block-size: var(--kbq-list-size-dragged-indicator-thickness);
+}
+
+.kbq-list-selection_horizontal .kbq-list-selection__drop-indicator {
+ top: 0;
+ bottom: 0;
+ left: calc(var(--kbq-list-drop-indicator-offset, 0px) - var(--kbq-list-size-dragged-indicator-thickness) / 2);
+ inline-size: var(--kbq-list-size-dragged-indicator-thickness);
+}
+
+// We should apply `cursor: grabbing` to the body, not to the dragged element itself.
+// Reason: https://github.com/angular/components/blob/18.2.14/src/cdk/drag-drop/resets.scss#L14-L15
+body:has(.kbq-list-selection.cdk-drop-list-dragging) {
+ cursor: grabbing;
+}
+
@include theme.kbq-list-theme();
@include theme.kbq-list-typography();
+
+// Declared after the theme so it outranks the default state, whose background is `transparent`
+// because an option normally sits on the surface of its list. The dragged clone has no such surface.
+// Selected states carry a higher specificity and deliberately keep their own background.
+.kbq-list-option.cdk-drag-preview {
+ background: var(--kbq-list-states-dragged-container-background);
+ box-shadow: var(--kbq-list-states-dragged-container-shadow);
+}
diff --git a/packages/docs-examples/components/list/index.ts b/packages/docs-examples/components/list/index.ts
index 316aa07703..617e2f98dd 100644
--- a/packages/docs-examples/components/list/index.ts
+++ b/packages/docs-examples/components/list/index.ts
@@ -1,5 +1,7 @@
import { NgModule } from '@angular/core';
import { ListActionButtonExample } from './list-action-button/list-action-button-example';
+import { ListDraggableConnectedExample } from './list-draggable-connected/list-draggable-connected-example';
+import { ListDraggableExample } from './list-draggable/list-draggable-example';
import { ListGroupsExample } from './list-groups/list-groups-example';
import { ListIntermediateStateExample } from './list-intermediate-state/list-intermediate-state-example';
import { ListMultipleCheckboxExample } from './list-multiple-checkbox/list-multiple-checkbox-example';
@@ -9,6 +11,8 @@ import { ListVirtualScrollExample } from './list-virtual-scroll/list-virtual-scr
export {
ListActionButtonExample,
+ ListDraggableConnectedExample,
+ ListDraggableExample,
ListGroupsExample,
ListIntermediateStateExample,
ListMultipleCheckboxExample,
@@ -24,7 +28,9 @@ const EXAMPLES = [
ListGroupsExample,
ListActionButtonExample,
ListIntermediateStateExample,
- ListVirtualScrollExample
+ ListVirtualScrollExample,
+ ListDraggableExample,
+ ListDraggableConnectedExample
];
@NgModule({
diff --git a/packages/docs-examples/components/list/list-draggable-connected/list-draggable-connected-example.ts b/packages/docs-examples/components/list/list-draggable-connected/list-draggable-connected-example.ts
new file mode 100644
index 0000000000..8da28b6ddc
--- /dev/null
+++ b/packages/docs-examples/components/list/list-draggable-connected/list-draggable-connected-example.ts
@@ -0,0 +1,98 @@
+import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
+import { ChangeDetectionStrategy, Component, signal, WritableSignal } from '@angular/core';
+import { KbqListModule, KbqListSelectionDroppedEvent } from '@koobiq/components/list';
+
+type Metric = { id: number; name: string };
+
+/**
+ * @title Draggable list with transfer between lists
+ */
+@Component({
+ selector: 'list-draggable-connected-example',
+ imports: [KbqListModule],
+ template: `
+
+
+ @for (metric of availableMetrics(); track metric.id) {
+ {{ metric.name }}
+ }
+
+
+
+ @for (metric of selectedMetrics(); track metric.id) {
+ {{ metric.name }}
+ }
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ListDraggableConnectedExample {
+ protected readonly availableMetrics = signal([
+ { id: 1, name: 'CPU load' },
+ { id: 2, name: 'Memory usage' },
+ { id: 3, name: 'Disk I/O' }
+ ]);
+
+ protected readonly selectedMetrics = signal([{ id: 4, name: 'Network traffic' }]);
+
+ protected dropped({
+ previousIndex,
+ currentIndex,
+ previousContainer,
+ container,
+ option
+ }: KbqListSelectionDroppedEvent): void {
+ // `previousContainer` and `container` identify the lists, but only the consumer knows which
+ // array backs which list — here the dragged value itself is the lookup key.
+ const fromAvailable = this.availableMetrics().includes(option.value);
+ const source = fromAvailable ? this.availableMetrics : this.selectedMetrics;
+
+ if (previousContainer === container) {
+ this.reorder(source, previousIndex, currentIndex);
+
+ return;
+ }
+
+ const target = fromAvailable ? this.selectedMetrics : this.availableMetrics;
+
+ this.transfer(source, target, previousIndex, currentIndex);
+ }
+
+ private reorder(list: WritableSignal, previousIndex: number, currentIndex: number): void {
+ const items = [...list()];
+
+ moveItemInArray(items, previousIndex, currentIndex);
+
+ list.set(items);
+ }
+
+ private transfer(
+ source: WritableSignal,
+ target: WritableSignal,
+ previousIndex: number,
+ currentIndex: number
+ ): void {
+ const from = [...source()];
+ const to = [...target()];
+
+ transferArrayItem(from, to, previousIndex, currentIndex);
+
+ source.set(from);
+ target.set(to);
+ }
+}
diff --git a/packages/docs-examples/components/list/list-draggable/list-draggable-example.ts b/packages/docs-examples/components/list/list-draggable/list-draggable-example.ts
new file mode 100644
index 0000000000..2cc8e153a8
--- /dev/null
+++ b/packages/docs-examples/components/list/list-draggable/list-draggable-example.ts
@@ -0,0 +1,36 @@
+import { moveItemInArray } from '@angular/cdk/drag-drop';
+import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
+import { KbqListModule, KbqListSelectionDroppedEvent } from '@koobiq/components/list';
+
+/**
+ * @title Draggable list
+ */
+@Component({
+ selector: 'list-draggable-example',
+ imports: [KbqListModule],
+ template: `
+
+ @for (item of items(); track item.id) {
+ {{ item.name }}
+ }
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ListDraggableExample {
+ protected readonly items = signal([
+ { id: 1, name: 'Critical' },
+ { id: 2, name: 'High' },
+ { id: 3, name: 'Medium' },
+ { id: 4, name: 'Low' },
+ { id: 5, name: 'Info' }
+ ]);
+
+ protected dropped({ previousIndex, currentIndex }: KbqListSelectionDroppedEvent): void {
+ const items = [...this.items()];
+
+ moveItemInArray(items, previousIndex, currentIndex);
+
+ this.items.set(items);
+ }
+}
diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts
index d29ac4b48b..89046c5ee4 100644
--- a/packages/docs-examples/example-module.ts
+++ b/packages/docs-examples/example-module.ts
@@ -3658,6 +3658,32 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = {
"primaryFile": "list-action-button-example.ts",
"importPath": "components/list"
},
+ "list-draggable-connected": {
+ "packagePath": "components/list/list-draggable-connected",
+ "title": "Draggable list with transfer between lists",
+ "componentName": "ListDraggableConnectedExample",
+ "files": [
+ "list-draggable-connected-example.ts"
+ ],
+ "localImportFiles": [],
+ "selector": "list-draggable-connected-example",
+ "additionalComponents": [],
+ "primaryFile": "list-draggable-connected-example.ts",
+ "importPath": "components/list"
+ },
+ "list-draggable": {
+ "packagePath": "components/list/list-draggable",
+ "title": "Draggable list",
+ "componentName": "ListDraggableExample",
+ "files": [
+ "list-draggable-example.ts"
+ ],
+ "localImportFiles": [],
+ "selector": "list-draggable-example",
+ "additionalComponents": [],
+ "primaryFile": "list-draggable-example.ts",
+ "importPath": "components/list"
+ },
"list-groups": {
"packagePath": "components/list/list-groups",
"title": "List groups",
@@ -8210,6 +8236,10 @@ return import('@koobiq/docs-examples/components/link');
case 'link-with-caption':
return import('@koobiq/docs-examples/components/link');
case 'list-action-button':
+return import('@koobiq/docs-examples/components/list');
+ case 'list-draggable-connected':
+return import('@koobiq/docs-examples/components/list');
+ case 'list-draggable':
return import('@koobiq/docs-examples/components/list');
case 'list-groups':
return import('@koobiq/docs-examples/components/list');
diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts
index 09d8602ce7..88d4806b58 100644
--- a/packages/e2e/routes.ts
+++ b/packages/e2e/routes.ts
@@ -62,7 +62,12 @@ import {
} from '../components/inline-edit/e2e';
import { E2eInputStateAndStyle } from '../components/input/e2e';
import { E2eLinkStates, E2eLinkWithCaption } from '../components/link/e2e';
-import { E2eListOptionActionVisibility, E2eListSelectionState, E2eListStates } from '../components/list/e2e';
+import {
+ E2eListDragAndDrop,
+ E2eListOptionActionVisibility,
+ E2eListSelectionState,
+ E2eListStates
+} from '../components/list/e2e';
import { E2eLoaderOverlayCard, E2eLoaderOverlayStates } from '../components/loader-overlay/e2e';
import { E2eMarkdownStates } from '../components/markdown/e2e';
import { E2eModalFullCustom, E2eModalStates } from '../components/modal/e2e';
@@ -213,6 +218,7 @@ const components = [
E2eListStates,
E2eListSelectionState,
E2eListOptionActionVisibility,
+ E2eListDragAndDrop,
E2eLoaderOverlayStates,
E2eLoaderOverlayCard,
E2eAutocompleteStates,
diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md
index 59ec202267..be1cffe648 100644
--- a/tools/public_api_guard/components/core.api.md
+++ b/tools/public_api_guard/components/core.api.md
@@ -451,6 +451,7 @@ export const enUSLocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -716,6 +717,7 @@ export const esLALocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -1223,6 +1225,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -1463,6 +1466,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -1698,6 +1702,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -1938,6 +1943,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -2175,6 +2181,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -2469,6 +2476,7 @@ export type KbqA11yLocaleConfiguration = {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
// @public
@@ -4455,6 +4463,7 @@ export const ptBRLocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -4905,6 +4914,7 @@ export const ruRULocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
@@ -5266,6 +5276,7 @@ export const tkTMLocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ listOptionMoved: string;
};
select: {
hiddenItemsText: string;
diff --git a/tools/public_api_guard/components/list.api.md b/tools/public_api_guard/components/list.api.md
index 59322dd933..a1b02635db 100644
--- a/tools/public_api_guard/components/list.api.md
+++ b/tools/public_api_guard/components/list.api.md
@@ -6,13 +6,15 @@
import { AfterContentInit } from '@angular/core';
import { AfterViewInit } from '@angular/core';
+import * as _angular_core from '@angular/core';
+import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { ControlValueAccessor } from '@angular/forms';
import { ElementRef } from '@angular/core';
import { EventEmitter } from '@angular/core';
import { FocusKeyManager } from '@koobiq/components/core';
import { FocusMonitor } from '@angular/cdk/a11y';
-import * as i0 from '@angular/core';
-import * as i1 from '@angular/cdk/a11y';
+import * as i1$1 from '@angular/cdk/a11y';
+import * as i1 from '@angular/cdk/drag-drop';
import * as i2 from '@koobiq/components/core';
import * as i3 from '@koobiq/components/icon';
import { IFocusableOption } from '@koobiq/components/core';
@@ -37,9 +39,9 @@ export const KBQ_SELECTION_LIST_VALUE_ACCESSOR: any;
// @public (undocumented)
export class KbqList {
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration;
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// @public
@@ -66,31 +68,33 @@ export class KbqListItem implements AfterContentInit {
// (undocumented)
ngAfterContentInit(): void;
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration;
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// @public (undocumented)
export class KbqListModule {
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
// (undocumented)
- static ɵinj: i0.ɵɵInjectorDeclaration;
+ static ɵinj: _angular_core.ɵɵInjectorDeclaration;
// (undocumented)
- static ɵmod: i0.ɵɵNgModuleDeclaration;
+ static ɵmod: _angular_core.ɵɵNgModuleDeclaration;
}
// @public
export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTitleTextRef {
+ constructor();
// (undocumented)
- readonly actionButton: i0.Signal;
+ readonly actionButton: _angular_core.Signal;
// (undocumented)
blur(): void;
// (undocumented)
- readonly checkboxPosition: i0.InputSignal<"before" | "after">;
+ readonly checkboxPosition: _angular_core.InputSignal<"before" | "after">;
get disabled(): boolean;
set disabled(value: boolean);
+ protected get draggable(): boolean;
// (undocumented)
dropdownTrigger?: KbqDropdownTrigger;
// (undocumented)
@@ -126,7 +130,7 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi
// (undocumented)
preventBlur: boolean;
// (undocumented)
- readonly pseudoCheckbox: i0.Signal;
+ readonly pseudoCheckbox: _angular_core.Signal;
// (undocumented)
get selected(): boolean;
set selected(value: boolean);
@@ -135,10 +139,11 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi
// (undocumented)
get showCheckbox(): any;
set showCheckbox(value: any);
+ syncDraggableState(): void;
// (undocumented)
get tabIndex(): any;
// (undocumented)
- readonly text: i0.Signal>;
+ readonly text: _angular_core.Signal>;
// (undocumented)
textElement: ElementRef;
// (undocumented)
@@ -149,17 +154,17 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi
get value(): any;
set value(newValue: any);
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration;
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// @public (undocumented)
export class KbqListOptionCaption {
// (undocumented)
- static ɵdir: i0.ɵɵDirectiveDeclaration;
+ static ɵdir: _angular_core.ɵɵDirectiveDeclaration;
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// @public (undocumented)
@@ -174,6 +179,8 @@ export class KbqListSelectAllEvent {
// @public (undocumented)
export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDestroy, ControlValueAccessor {
constructor();
+ protected readonly announcement: _angular_core.WritableSignal;
+ get ariaKeyShortcuts(): string;
// (undocumented)
get autoSelect(): boolean;
set autoSelect(value: boolean);
@@ -181,23 +188,30 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
blur(): void;
// (undocumented)
canDeselectLast(listOption: KbqListOption): boolean;
- readonly compareWith: i0.InputSignal<(o1: any, o2: any) => boolean>;
+ clearDropIndicators(option: KbqListOption): void;
+ readonly compareWith: _angular_core.InputSignal<(o1: any, o2: any) => boolean>;
+ readonly connectedTo: _angular_core.InputSignal;
// (undocumented)
deselectAll(): void;
// (undocumented)
get disabled(): boolean;
set disabled(value: boolean);
+ get draggable(): boolean;
+ set draggable(value: boolean);
+ protected readonly dropIndicatorOffset: _angular_core.WritableSignal;
+ readonly dropped: _angular_core.OutputEmitterRef;
// (undocumented)
emitChangeEvent(option: KbqListOption): void;
// (undocumented)
focus(): void;
// (undocumented)
protected readonly focusMonitor: FocusMonitor;
+ focusOptionByValue(value: unknown): boolean;
getHeight(): number;
// (undocumented)
getSelectedOptionValues(): string[];
// (undocumented)
- readonly horizontal: i0.InputSignalWithTransform;
+ readonly horizontal: _angular_core.InputSignalWithTransform;
// (undocumented)
keyManager: FocusKeyManager;
// (undocumented)
@@ -207,6 +221,8 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
// (undocumented)
static ngAcceptInputType_disabled: unknown;
// (undocumented)
+ static ngAcceptInputType_draggable: unknown;
+ // (undocumented)
ngAfterContentInit(): void;
// (undocumented)
ngAfterViewInit(): void;
@@ -218,8 +234,12 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
// (undocumented)
readonly onCopy: EventEmitter>;
onKeyDown(event: KeyboardEvent): void;
+ onOptionDragMoved(option: KbqListOption, pointer: {
+ x: number;
+ y: number;
+ }): void;
// (undocumented)
- readonly onSelectAll: i0.OutputEmitterRef>;
+ readonly onSelectAll: _angular_core.OutputEmitterRef>;
// (undocumented)
onTouched: () => void;
// (undocumented)
@@ -242,9 +262,9 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
selectAll(): void;
get selectAllHandler(): (event: KeyboardEvent, list: KbqListSelection) => void;
set selectAllHandler(fn: (event: KeyboardEvent, list: KbqListSelection) => void);
- readonly selectAllToggle: i0.InputSignalWithTransform;
+ readonly selectAllToggle: _angular_core.InputSignalWithTransform;
// (undocumented)
- readonly selectionChange: i0.OutputEmitterRef;
+ readonly selectionChange: _angular_core.OutputEmitterRef;
// (undocumented)
selectionModel: SelectionModel;
// (undocumented)
@@ -271,9 +291,9 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest
// (undocumented)
writeValue(values: string[]): void;
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration;
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// @public (undocumented)
@@ -285,6 +305,14 @@ export class KbqListSelectionChange {
source: KbqListSelection;
}
+// @public
+export type KbqListSelectionDroppedEvent = Pick, 'previousIndex' | 'currentIndex'> & {
+ option: KbqListOption;
+ container: KbqListSelection;
+ previousContainer: KbqListSelection;
+ event: MouseEvent | TouchEvent | KeyboardEvent;
+};
+
// @public (undocumented)
export interface KbqOptionEvent {
// (undocumented)