diff --git a/packages/components-dev/list/module.ts b/packages/components-dev/list/module.ts index f322a0b1c6..0080e962c8 100644 --- a/packages/components-dev/list/module.ts +++ b/packages/components-dev/list/module.ts @@ -1,4 +1,5 @@ import { Clipboard } from '@angular/cdk/clipboard'; +import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { AsyncPipe, JsonPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core'; @@ -6,7 +7,7 @@ import { FormsModule, UntypedFormControl } from '@angular/forms'; import { PopUpPlacements } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; -import { KbqListModule, KbqListSelectionChange } from '@koobiq/components/list'; +import { KbqListModule, KbqListSelectionChange, KbqListSelectionDroppedEvent } from '@koobiq/components/list'; import { KbqTitleModule } from '@koobiq/components/title'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; import { ListExamplesModule } from 'packages/docs-examples/components/list'; @@ -14,6 +15,11 @@ import { of } from 'rxjs'; import { debounceTime, startWith, switchMap } from 'rxjs/operators'; import { DevThemeToggle } from '../theme-toggle'; +type DevItem = { id: number; label: string }; + +const devItems = (prefix: string, offset: number, length: number): DevItem[] => + Array.from({ length }, (_, i) => ({ id: offset + i, label: `${prefix} #${offset + i}` })); + @Component({ selector: 'dev-examples', imports: [ListExamplesModule], @@ -28,6 +34,12 @@ import { DevThemeToggle } from '../theme-toggle';

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