From 7758c74d178ec07de232606b84726b5e2efd7871 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Mon, 17 Aug 2026 21:31:40 +0300 Subject: [PATCH 1/4] feat(list): drag and drop reordering (#DS-4454) Options of `kbq-list-selection` can be reordered by dragging or with `Alt` + arrow keys once `draggable` is set, and moved between lists connected via `connectedTo`. The list never mutates the projected data: it reports the move through the `dropped` event and the consumer applies it. The new position is announced to assistive tech only once that move has actually been applied, and `aria-keyshortcuts` advertises the keyboard alternative to dragging. Dragging inside `kbq-optgroup` or `cdk-virtual-scroll-viewport` reports indices that do not address the backing array, so both now warn in development mode. --- packages/components-dev/list/module.ts | 51 +- packages/components-dev/list/template.html | 42 ++ packages/components/core/locales/en-US.ts | 3 +- packages/components/core/locales/es-LA.ts | 3 +- packages/components/core/locales/pt-BR.ts | 3 +- packages/components/core/locales/ru-RU.ts | 3 +- packages/components/core/locales/tk-TM.ts | 3 +- packages/components/core/locales/types.ts | 10 +- .../components/list/e2e.playwright-spec.ts | 149 ++++++ packages/components/list/e2e.ts | 79 ++- .../list/list-selection.component.spec.ts | 476 +++++++++++++++++- .../list/list-selection.component.ts | 349 ++++++++++++- packages/components/list/list-tokens.scss | 8 +- packages/components/list/list.en.md | 35 ++ packages/components/list/list.ru.md | 34 ++ packages/components/list/list.scss | 37 ++ .../docs-examples/components/list/index.ts | 8 +- .../list-draggable-connected-example.ts | 98 ++++ .../list-draggable/list-draggable-example.ts | 36 ++ packages/docs-examples/example-module.ts | 30 ++ packages/e2e/routes.ts | 8 +- tools/public_api_guard/components/core.api.md | 11 + tools/public_api_guard/components/list.api.md | 35 +- 23 files changed, 1489 insertions(+), 22 deletions(-) create mode 100644 packages/docs-examples/components/list/list-draggable-connected/list-draggable-connected-example.ts create mode 100644 packages/docs-examples/components/list/list-draggable/list-draggable-example.ts 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..9d3a3ac481 100644 --- a/packages/components/list/e2e.playwright-spec.ts +++ b/packages/components/list/e2e.playwright-spec.ts @@ -117,4 +117,153 @@ test.describe('KbqListModule', () => { await expect(getOptionAction(page, 'option-1')).toBeVisible(); }); }); + + test.describe('E2eListDragAndDrop', () => { + // The suite runs with `reducedMotion: 'reduce'`, which would make the settling assertion below + // pass without ever exercising a transition. + test.use({ reducedMotion: 'no-preference' }); + + 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, and only sorts once the pointer actually + * moves — hence the stepped move rather than a single jump. + */ + 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 / 2; + + 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); + }; + + /** 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 + ); + + /** + * Has to outlast both drag transitions declared in `list.scss` — the 250ms sort transition and + * the 300ms `.cdk-drag-animating` reset — with room to spare on a frame-throttled CI machine. + * Keep in sync with those durations. + */ + const settlingDuration = 700; + + 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('settles into the new order without sliding into place', async ({ page }) => { + await pressAndMoveOnto(page, 'source-1', 'source-3'); + + const sampling = sampleFrames(page, 'e2eSourceList', settlingDuration); + + await page.mouse.up(); + + const frames = await sampling; + const settled = frames.at(-1)!; + // Once the consumer has applied the move, the options are already where they belong. If the + // sort transforms are reset with a transition still live, the reset animates on top of the + // new DOM order and every option below the dropped one visibly slides — those frames carry + // the new order but the old offsets. + const reordered = frames.filter((frame) => frame.startsWith('source-2')); + + expect(reordered.length).toBeGreaterThan(0); + expect([...new Set(reordered)]).toEqual([settled]); + }); + + 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..b55857e961 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,355 @@ 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; + }; + + 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 = fixture.debugElement.queryAll(By.directive(KbqListOption))[0].componentInstance; + const dropList = getDropList(fixture); + const nativeEvent = createMouseEvent('mouseup'); + + dropList.dropped.emit({ + previousIndex: 0, + currentIndex: 2, + item: { data: { option } } as any, + container: dropList, + previousContainer: dropList, + isPointerOverContainer: true, + distance: { x: 0, y: 0 }, + dropPoint: { x: 0, y: 0 }, + event: nativeEvent + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.dropped).toEqual({ + previousIndex: 0, + currentIndex: 2, + option, + container: list, + previousContainer: list, + event: nativeEvent + }); + }); + }); + + 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(); + }); + }); +}); + @Component({ imports: [ KbqListModule, @@ -1574,3 +1929,122 @@ 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; +} diff --git a/packages/components/list/list-selection.component.ts b/packages/components/list/list-selection.component.ts index 931470a041..bc9dc10cee 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,41 @@ export class KbqListCopyEvent { ) {} } +/** + * Data attached to the underlying `CdkDrag` while a list option is being dragged. + * + * @docs-private + */ +export 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: ` + +
{{ announcement() }}
`, styleUrls: ['./list.scss', 'list-tokens.scss'], providers: [KBQ_SELECTION_LIST_VALUE_ACCESSOR], @@ -118,6 +154,7 @@ export class KbqListCopyEvent { encapsulation: ViewEncapsulation.None, host: { class: 'kbq-list-selection', + '[class.kbq-list-selection_draggable]': 'draggable', '[attr.tabindex]': 'tabIndex', '[attr.disabled]': 'disabled || null', '(keydown)': 'onKeyDown($event)', @@ -125,6 +162,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 +208,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 +281,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest set disabled(value: boolean) { if (value !== this.disabled) { this._disabled = value; + this.syncDraggableState(); } } @@ -238,11 +317,18 @@ 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(''); private optionFocusSubscription: Subscription | null; private optionBlurSubscription: Subscription | null; + private pendingMoveSubscription: Subscription | null; + constructor() { const multiple = inject(new HostAttributeToken('multiple'), { optional: true }); @@ -258,6 +344,8 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest } this.selectionModel = new SelectionModel(this.multiple); + + this.setupDropListInitialProperties(); } ngAfterContentInit(): void { @@ -298,6 +386,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest if (!this.platform.isBrowser) return; + this.warnOnUnsupportedDragContainer(); this.updateScrollSize(); } @@ -321,6 +410,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 +500,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 +608,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 +724,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 +751,197 @@ 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; + 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(); + }); + + this.dropList.dropped + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ previousIndex, currentIndex, previousContainer, container, item, event }) => { + const { option }: KbqListOptionDragData = item.data; + + this.emitDropped({ + option, + previousIndex, + currentIndex, + previousContainer: previousContainer.data, + container: container.data, + 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 { + if (!isDevMode() || !this.draggable) { + return; + } + + if (this.elementRef.nativeElement.querySelector('cdk-virtual-scroll-viewport')) { + // 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 (this.options.some((option) => !!option.group)) { + // 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.' + ); + } + } + + /** 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.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)); + } + + /** + * 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 +1036,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 +1056,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 +1118,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 +1165,29 @@ 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. + this.drag.started.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.drag.data = { option: this }; + }); + } + + /** + * 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..cb37c2aad2 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); diff --git a/packages/components/list/list.en.md b/packages/components/list/list.en.md index e8eeb7d200..c54fa4cede 100644 --- a/packages/components/list/list.en.md +++ b/packages/components/list/list.en.md @@ -21,3 +21,38 @@ ### Virtual scroll + +### Drag and drop + +Set the `draggable` property on `kbq-list-selection` to let the user reorder options. + +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. diff --git a/packages/components/list/list.ru.md b/packages/components/list/list.ru.md index e8eeb7d200..d04d853ae6 100644 --- a/packages/components/list/list.ru.md +++ b/packages/components/list/list.ru.md @@ -21,3 +21,37 @@ ### 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`, а не по ссылке на +компонент, с клавиатуры добраться нельзя. diff --git a/packages/components/list/list.scss b/packages/components/list/list.scss index 92e8f4af4d..520027b46d 100644 --- a/packages/components/list/list.scss +++ b/packages/components/list/list.scss @@ -37,5 +37,42 @@ } } +// Gated on the placeholder rather than on `.cdk-drop-list-dragging`: that class is an Angular host +// binding, so it survives until the next change detection — long enough for CDK to clear the sort +// transforms while this transition is still live. The reset would then animate on top of the DOM +// order the consumer has already applied, and every option below the dropped one visibly slides +// into place. CDK removes the placeholder from the DOM synchronously, before that reset. +.kbq-list-selection:has(.cdk-drag-placeholder) .cdk-drag { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.kbq-list-option { + &.kbq-list-option_draggable { + cursor: grab; + } + + &.cdk-drag-placeholder { + opacity: var(--kbq-opacity-disabled); + } + + &.cdk-drag-animating { + transition: transform 300ms cubic-bezier(0, 0, 0.2, 1); + } +} + +// 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..89cd6b2bc6 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 { 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'; @@ -78,11 +80,12 @@ export class KbqListModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTitleTextRef { + constructor(); // (undocumented) readonly actionButton: i0.Signal; // (undocumented) @@ -91,6 +94,7 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi readonly checkboxPosition: i0.InputSignal<"before" | "after">; get disabled(): boolean; set disabled(value: boolean); + protected get draggable(): boolean; // (undocumented) dropdownTrigger?: KbqDropdownTrigger; // (undocumented) @@ -135,6 +139,7 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi // (undocumented) get showCheckbox(): any; set showCheckbox(value: any); + syncDraggableState(): void; // (undocumented) get tabIndex(): any; // (undocumented) @@ -149,7 +154,7 @@ export class KbqListOption implements OnDestroy, OnInit, IFocusableOption, KbqTi get value(): any; set value(newValue: any); // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -162,6 +167,11 @@ export class KbqListOptionCaption { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export type KbqListOptionDragData = { + option: KbqListOption; +}; + // @public (undocumented) export class KbqListSelectAllEvent { constructor(source: KbqListSelection, options: T[]); @@ -174,6 +184,8 @@ export class KbqListSelectAllEvent { // @public (undocumented) export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDestroy, ControlValueAccessor { constructor(); + protected readonly announcement: i0.WritableSignal; + get ariaKeyShortcuts(): string; // (undocumented) get autoSelect(): boolean; set autoSelect(value: boolean); @@ -182,17 +194,22 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // (undocumented) canDeselectLast(listOption: KbqListOption): boolean; readonly compareWith: i0.InputSignal<(o1: any, o2: any) => boolean>; + readonly connectedTo: i0.InputSignal; // (undocumented) deselectAll(): void; // (undocumented) get disabled(): boolean; set disabled(value: boolean); + get draggable(): boolean; + set draggable(value: boolean); + readonly dropped: i0.OutputEmitterRef; // (undocumented) emitChangeEvent(option: KbqListOption): void; // (undocumented) focus(): void; // (undocumented) protected readonly focusMonitor: FocusMonitor; + focusOptionByValue(value: unknown): boolean; getHeight(): number; // (undocumented) getSelectedOptionValues(): string[]; @@ -207,6 +224,8 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_draggable: unknown; + // (undocumented) ngAfterContentInit(): void; // (undocumented) ngAfterViewInit(): void; @@ -271,7 +290,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // (undocumented) writeValue(values: string[]): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -285,6 +304,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) From e6fbf46da0f6570a2fe85e5f6336c58e2e1f42aa Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 18 Aug 2026 13:03:18 +0300 Subject: [PATCH 2/4] fix(list): address review comments on drag and drop (#DS-4454) `KbqListOptionDragData` is only read inside the component file, so it no longer leaves the package and no longer widens the public API surface. `warnOnUnsupportedDragContainer()` now also runs from `syncDraggableState()`, so enabling `draggable` after the initial render still warns. It tolerates being called before the content children exist and reports each list only once. --- .../list/list-selection.component.spec.ts | 57 +++++++++++++++++++ .../list/list-selection.component.ts | 23 +++++--- tools/public_api_guard/components/list.api.md | 5 -- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/packages/components/list/list-selection.component.spec.ts b/packages/components/list/list-selection.component.spec.ts index b55857e961..a4aa627b2b 100644 --- a/packages/components/list/list-selection.component.spec.ts +++ b/packages/components/list/list-selection.component.spec.ts @@ -1621,6 +1621,47 @@ describe('KbqListSelection drag and drop', () => { 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({ @@ -2048,3 +2089,19 @@ class IdConnectedSelectionLists { 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 bc9dc10cee..4070bb438d 100644 --- a/packages/components/list/list-selection.component.ts +++ b/packages/components/list/list-selection.component.ts @@ -113,11 +113,10 @@ export class KbqListCopyEvent { } /** - * Data attached to the underlying `CdkDrag` while a list option is being dragged. - * - * @docs-private + * 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`. */ -export type KbqListOptionDragData = { option: KbqListOption }; +type KbqListOptionDragData = { option: KbqListOption }; /** Event emitted when an option changes its position by dragging or by keyboard. */ export type KbqListSelectionDroppedEvent = Pick, 'previousIndex' | 'currentIndex'> & { @@ -329,6 +328,8 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest private pendingMoveSubscription: Subscription | null; + private hasWarnedOnDragContainer = false; + constructor() { const multiple = inject(new HostAttributeToken('multiple'), { optional: true }); @@ -785,11 +786,16 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest * item, so warn instead of letting it pass unnoticed. */ private warnOnUnsupportedDragContainer(): void { - if (!isDevMode() || !this.draggable) { + // 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; } - if (this.elementRef.nativeElement.querySelector('cdk-virtual-scroll-viewport')) { + 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 ' + @@ -797,19 +803,22 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest ); } - if (this.options.some((option) => !!option.group)) { + 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(); } diff --git a/tools/public_api_guard/components/list.api.md b/tools/public_api_guard/components/list.api.md index 89cd6b2bc6..0f7a38a5fa 100644 --- a/tools/public_api_guard/components/list.api.md +++ b/tools/public_api_guard/components/list.api.md @@ -167,11 +167,6 @@ export class KbqListOptionCaption { static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public -export type KbqListOptionDragData = { - option: KbqListOption; -}; - // @public (undocumented) export class KbqListSelectAllEvent { constructor(source: KbqListSelection, options: T[]); From 58c1fe44874b6cf650315f304035f758c9b90b1f Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 18 Aug 2026 14:22:07 +0300 Subject: [PATCH 3/4] feat(list): mark the drop target with an insertion indicator (#DS-4454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging an option no longer opens a gap. The surrounding options stay where they are, the dragged one keeps its slot as a faded row, and a line with a dot marks the position the option would land in — in the connected list too, and along the main axis of a horizontal list. CDK's sorting is switched off to keep the list still, which also makes the index it reports on drop unusable, so the target is derived from the pointer instead. The dragged option is left out of that measurement, which makes the resulting gap index the very index `moveItemInArray` and `transferArrayItem` expect. Three CDK behaviours had to be handled: `enter()` ignores `sortingDisabled` and pushes the placeholder into whichever list is hovered, so a foreign placeholder is hidden; `hasAnchor` keeps the faded row in the origin list during a transfer; and the drop animation is dropped, because it would fly the preview back to the slot the drag started from and read as a rejected drop. --- .../components/list/e2e.playwright-spec.ts | 91 ++++++--- .../list/list-selection.component.spec.ts | 180 ++++++++++++++++-- .../list/list-selection.component.ts | 142 +++++++++++++- packages/components/list/list-tokens.scss | 9 + packages/components/list/list.en.md | 7 +- packages/components/list/list.ru.md | 6 +- packages/components/list/list.scss | 75 ++++++-- tools/public_api_guard/components/list.api.md | 58 +++--- 8 files changed, 487 insertions(+), 81 deletions(-) diff --git a/packages/components/list/e2e.playwright-spec.ts b/packages/components/list/e2e.playwright-spec.ts index 9d3a3ac481..201afde710 100644 --- a/packages/components/list/e2e.playwright-spec.ts +++ b/packages/components/list/e2e.playwright-spec.ts @@ -119,16 +119,14 @@ test.describe('KbqListModule', () => { }); test.describe('E2eListDragAndDrop', () => { - // The suite runs with `reducedMotion: 'reduce'`, which would make the settling assertion below - // pass without ever exercising a transition. - test.use({ reducedMotion: 'no-preference' }); - 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, and only sorts once the pointer actually - * moves — hence the stepped move rather than a single jump. + * 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())!; @@ -136,7 +134,7 @@ test.describe('KbqListModule', () => { 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 / 2; + const endY = targetBox.y + targetBox.height * 0.75; await page.mouse.move(startX, startY); await page.mouse.down(); @@ -158,6 +156,19 @@ test.describe('KbqListModule', () => { 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( @@ -185,13 +196,6 @@ test.describe('KbqListModule', () => { [list, duration] as const ); - /** - * Has to outlast both drag transitions declared in `list.scss` — the 250ms sort transition and - * the 300ms `.cdk-drag-animating` reset — with room to spare on a frame-throttled CI machine. - * Keep in sync with those durations. - */ - const settlingDuration = 700; - test.beforeEach(async ({ page }) => { await page.goto('/E2eListDragAndDrop'); }); @@ -204,23 +208,60 @@ test.describe('KbqListModule', () => { expect(await getLabels(page, 'e2eSourceList')).toEqual(['source-2', 'source-3', 'source-1']); }); - test('settles into the new order without sliding into place', async ({ page }) => { + 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'); - const sampling = sampleFrames(page, 'e2eSourceList', settlingDuration); + // 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(); + }); - const frames = await sampling; - const settled = frames.at(-1)!; - // Once the consumer has applied the move, the options are already where they belong. If the - // sort transforms are reset with a transition still live, the reset animates on top of the - // new DOM order and every option below the dropped one visibly slides — those frames carry - // the new order but the old offsets. - const reordered = frames.filter((frame) => frame.startsWith('source-2')); + test('moves the indicator into the connected list when hovering it', async ({ page }) => { + await pressAndMoveOnto(page, 'source-1', 'target-1'); - expect(reordered.length).toBeGreaterThan(0); - expect([...new Set(reordered)]).toEqual([settled]); + 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 }) => { diff --git a/packages/components/list/list-selection.component.spec.ts b/packages/components/list/list-selection.component.spec.ts index a4aa627b2b..d28120948d 100644 --- a/packages/components/list/list-selection.component.spec.ts +++ b/packages/components/list/list-selection.component.spec.ts @@ -1295,6 +1295,55 @@ describe('KbqListSelection drag and drop', () => { 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. @@ -1379,22 +1428,13 @@ describe('KbqListSelection drag and drop', () => { it('should re-emit a CDK drop as a KbqListSelectionDroppedEvent', () => { const fixture = setup(SelectionListWithDragAndDrop); const list = fixture.componentInstance.list(); - const option = fixture.debugElement.queryAll(By.directive(KbqListOption))[0].componentInstance; - const dropList = getDropList(fixture); + const option = getOptions(fixture)[0]; const nativeEvent = createMouseEvent('mouseup'); - dropList.dropped.emit({ - previousIndex: 0, - currentIndex: 2, - item: { data: { option } } as any, - container: dropList, - previousContainer: dropList, - isPointerOverContainer: true, - distance: { x: 0, y: 0 }, - dropPoint: { x: 0, y: 0 }, - event: nativeEvent - }); - fixture.detectChanges(); + 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, @@ -1405,6 +1445,118 @@ describe('KbqListSelection drag and drop', () => { 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', () => { diff --git a/packages/components/list/list-selection.component.ts b/packages/components/list/list-selection.component.ts index 4070bb438d..67cf0d4f33 100644 --- a/packages/components/list/list-selection.component.ts +++ b/packages/components/list/list-selection.component.ts @@ -141,6 +141,14 @@ const isAltOnly = (event: KeyboardEvent): boolean => selector: 'kbq-list-selection', template: ` + @if (dropIndicatorOffset() !== null) { + + + }