diff --git a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.spec.ts b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.spec.ts index cb5e935b9747..a2601fd9de23 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.spec.ts @@ -48,8 +48,13 @@ import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing import { HalResourceEditingService, ResourceChangesetCommit } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; +import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; import { buildQuery, buildTable, TableHarness, TableHarnessOptions } from '../wp-fast-table/testing/table-harness'; +import { buildWorkPackage } from '../wp-fast-table/testing/work-package-fixture'; import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HAL_NEW_RESOURCE_ID } from 'core-app/features/hal/helpers/is-new-resource'; +import { wpOccurrenceKey } from '../wp-fast-table/rendered-occurrence-ledger'; function deferred() { let resolve!:(value:T) => void; @@ -58,10 +63,6 @@ function deferred() { return { promise, resolve, reject }; } -async function flush() { - for (let turn = 0; turn < 20; turn += 1) await Promise.resolve(); -} - describe('TableEditForm activation lifetime', () => { let harness:TableHarness; const resourceInjectors:DestroyableInjector[] = []; @@ -134,18 +135,20 @@ describe('TableEditForm activation lifetime', () => { }); it.each([false, true])('settles a pending click with the correct notice and read-only state (destroyed: %s)', async (destroyed) => { - await mount(); + const form = await mount(); + const activate = vi.spyOn(form, 'activate'); const target = harness.row('1').querySelector('.subject .inline-edit--display-field')!; const notice = vi.spyOn(harness.injector.get(HalResourceNotificationService), 'handleRawError'); vi.spyOn(console, 'error').mockImplementation(() => undefined); timers(); fireEvent.click(target); + const activation = activate.mock.results[0].value as ReturnType; harness.row('1').querySelector('.subject')!.remove(); await startWait(); await vi.advanceTimersByTimeAsync(4999); if (destroyed) harness.table.destroy(); await vi.advanceTimersByTimeAsync(1); - await flush(); + await activation; expect(target.classList.contains('-read-only')).toBe(!destroyed); expect(notice.mock.calls.length).toBe(destroyed ? 0 : 1); expect(vi.getTimerCount()).toBe(0); @@ -169,11 +172,11 @@ describe('TableEditForm activation lifetime', () => { const change = form.change; vi.spyOn(harness.injector.get(HalResourceEditingService), 'changeFor').mockReturnValue(change); vi.spyOn(change.schema, 'ofProperty').mockReturnValue(null as never); - vi.spyOn(change, 'getForm').mockReturnValue(schema.promise as never); + const getForm = vi.spyOn(change, 'getForm').mockReturnValue(schema.promise as never); const create = vi.spyOn(harness.injector.get(EditingPortalService), 'create'); const notice = vi.spyOn(harness.injector.get(HalResourceNotificationService), 'handleRawError'); const activation = form.activate('subject'); - await flush(); + await waitFor(() => expect(getForm).toHaveBeenCalled()); await harness.destroy(); schema.resolve(); await expect(activation).resolves.toBeUndefined(); @@ -188,11 +191,11 @@ describe('TableEditForm activation lifetime', () => { const change = form.change; vi.spyOn(harness.injector.get(HalResourceEditingService), 'changeFor').mockReturnValue(change); vi.spyOn(change.schema, 'ofProperty').mockReturnValue(null as never); - vi.spyOn(change, 'getForm').mockReturnValue(schema.promise as never); + const getForm = vi.spyOn(change, 'getForm').mockReturnValue(schema.promise as never); const notice = vi.spyOn(harness.injector.get(HalResourceNotificationService), 'handleRawError'); vi.spyOn(console, 'error').mockImplementation(() => undefined); const activation = form.activate('subject'); - await flush(); + await waitFor(() => expect(getForm).toHaveBeenCalled()); await harness.destroy(); const error = new Error('schema request failed'); schema.reject(error); @@ -221,32 +224,37 @@ describe('TableEditForm activation lifetime', () => { await expect(activation).resolves.toBeUndefined(); expect(form.activeFields).toEqual({}); expect(within(harness.row('1')).queryByRole('textbox')).not.toBeInTheDocument(); - await flush(); expect(focus).not.toHaveBeenCalled(); expect(target).not.toHaveClass('-read-only'); }); it('continues required-column activation after a valid form-destroying redraw', async () => { const loading = deferred(); - const form = await mount({ columns: ['id'], providers: [{ provide: WorkPackagesListService, useValue: { conditionallyLoadForm: () => loading.promise } }] }); + const conditionallyLoadForm = vi.fn(() => loading.promise); + const form = await mount({ columns: ['id'], providers: [{ provide: WorkPackagesListService, useValue: { conditionallyLoadForm } }] }); harness.querySpace.available.columns.putValue(buildQuery(['id', 'subject'], null, false, false).columns); const destroy = vi.spyOn(harness.table.editing.forms['1'], 'destroy'); const activation = form.activateWhenNeeded('subject'); - await flush(); + await waitFor(() => expect(conditionallyLoadForm).toHaveBeenCalled()); loading.resolve(); const handler = await activation as EditFieldHandler; handler.focus(); expect(destroy).toHaveBeenCalled(); expect(harness.table.destroyed).toBe(false); - expect(within(harness.row('1')).getByRole('textbox')).toHaveFocus(); + expect(harness.table.editing.forms['1']).toBe(form); + const input = within(harness.row('1')).getByRole('textbox'); + expect(input).toHaveFocus(); + harness.table.refreshRows(harness.injector.get(States).workPackages.get('1').value!); + expect(within(harness.row('1')).getByRole('textbox')).toBe(input); }); it('starts no DOM deadline during query-form loading and cancels the late result', async () => { const loading = deferred(); - const form = await mount({ columns: ['id'], providers: [{ provide: WorkPackagesListService, useValue: { conditionallyLoadForm: () => loading.promise } }] }); + const conditionallyLoadForm = vi.fn(() => loading.promise); + const form = await mount({ columns: ['id'], providers: [{ provide: WorkPackagesListService, useValue: { conditionallyLoadForm } }] }); timers(); const activation = form.activateWhenNeeded('subject'); - await flush(); + await waitFor(() => expect(conditionallyLoadForm).toHaveBeenCalled()); expect(vi.getTimerCount()).toBe(0); await vi.advanceTimersByTimeAsync(6000); harness.table.destroy(); @@ -290,8 +298,7 @@ describe('TableEditForm activation lifetime', () => { vi.spyOn(window, 'queueMicrotask').mockImplementation((callback) => queued.push(callback)); const tick = vi.spyOn(harness.injector.get(ApplicationRef), 'tick'); await expect(form.submit()).rejects.toBe(error); - await flush(); - expect(queued.length).toBeGreaterThan(0); + await waitFor(() => expect(queued.length).toBeGreaterThan(0)); tick.mockClear(); const external = document.createElement('input'); document.body.appendChild(external); @@ -378,3 +385,304 @@ describe('TableEditForm activation lifetime', () => { expect(change.contains('subject')).toBe(outcome !== 'success'); }); }); + +describe('TableEditForm origin', () => { + let harness:TableHarness; + + afterEach(async () => { + await harness.destroy(); + }); + + it('an editor opened by click remembers the cell it opened from as origin', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }], + editing: {}, + productionMarkup: true, + configuration: { keyboardNavigationEnabled: true }, + }); + await harness.render(); + const wp = harness.injector.get(States).workPackages.get('1').value!; + harness.table.editing.startEditing(wp, harness.row('1').dataset.classIdentifier!); + const form = harness.table.editing.forms['1']; + const subject = harness.row('1').querySelector('td.subject')!; + const index = Array.from(harness.row('1').cells).filter((cell) => cell.classList.contains('wp-table--navigable-cell')).indexOf(subject); + + expect(form.origin).toBeNull(); + fireEvent.mouseDown(subject, { button: 0 }); + await form.activate('subject'); + + expect(index).toBeGreaterThan(-1); + expect(form.origin).toMatchObject({ cell: index }); + harness.table.keyboardNavigation!.recordPosition({ key: 'header', cell: 0, desiredColumn: 0 }); + expect(form.origin).toMatchObject({ cell: index }); + + form.closeEditFields(); + expect(form.origin).toBeNull(); + }); + + describe('with a remembered position elsewhere', () => { + const mountTwoRows = async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + editing: {}, + productionMarkup: true, + configuration: { keyboardNavigationEnabled: true }, + }); + await harness.render(); + harness.table.keyboardNavigation!.recordPosition({ key: wpOccurrenceKey('2'), cell: 0, desiredColumn: 0 }); + }; + const formFor = (id:string) => { + const wp = harness.injector.get(States).workPackages.get(id).value!; + harness.table.editing.startEditing(wp, harness.row(id).dataset.classIdentifier!); + return harness.table.editing.forms[id]; + }; + + it('an editor activated programmatically returns focus to its own cell on cancel', async () => { + await mountTwoRows(); + const handler = await formFor('1').activate('subject'); + handler!.focus(); + + handler!.handleUserCancel(); + + expect(harness.row('1').querySelector('td.subject')).toHaveFocus(); + }); + + it('an editor in the inline-create row has no origin', async () => { + await mountTwoRows(); + const row = harness.addInlineCreateRow(); + const form = harness.table.editing.forms[HAL_NEW_RESOURCE_ID]; + + const handler = await form.activate('subject'); + + expect(handler).toBeDefined(); + expect(row).toContainElement(within(row).getByRole('textbox')); + expect(form.origin).toBeNull(); + }); + + it('a failed activation leaves no origin behind', async () => { + await mountTwoRows(); + vi.spyOn(harness.injector.get(EditingPortalService), 'create').mockRejectedValue(new Error('portal failed')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const form = formFor('1'); + + await expect(form.activate('subject')).resolves.toBeUndefined(); + + expect(form.origin).toBeNull(); + vi.restoreAllMocks(); + }); + }); +}); + +describe('TableEditForm focus return', () => { + let harness:TableHarness; + const outside = document.createElement('button'); + + const mount = async (options:Partial = {}, keyboardNavigationEnabled = true) => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + editing: {}, + productionMarkup: true, + ...options, + configuration: { keyboardNavigationEnabled }, + }); + await harness.render(); + }; + const subjectCell = (id = '1') => harness.row(id).querySelector('td.subject')!; + const openEditor = async (id = '1') => { + const display = subjectCell(id).querySelector('.inline-edit--display-field')!; + fireEvent.mouseDown(display, { button: 0 }); + fireEvent.click(display); + await waitFor(() => expect(within(harness.row(id)).getByRole('textbox')).toHaveFocus()); + return harness.table.editing.forms[id].activeFields.subject; + }; + const saveAs = (subject:string, id = '1') => () => { + const saved = buildWorkPackage({ id, subject }); + harness.injector.get(States).workPackages.get('1').putValue(saved); + return Promise.resolve({ resource: saved, wasNew: false }); + }; + + afterEach(async () => { + outside.remove(); + await harness.destroy(); + vi.restoreAllMocks(); + }); + + it('cancelling an editor opened by click returns focus to the origin cell', async () => { + await mount(); + const handler = await openEditor(); + + handler.handleUserCancel(); + + expect(subjectCell()).toHaveFocus(); + }); + + it('saving an editor returns focus to the origin cell after the row refresh', async () => { + await mount({ editing: { save: saveAs('Changed') } }); + const handler = await openEditor(); + const idCell = harness.row('1').querySelector('td.id'); + + await handler.handleUserSubmit(); + + expect(harness.row('1').querySelector('td.id')).not.toBe(idCell); + expect(subjectCell()).toHaveFocus(); + }); + + it('an unchanged-value submit returns focus to the origin cell', async () => { + await mount(); + const handler = await openEditor(); + + await handler.handleUserSubmit(); + + expect(subjectCell()).toHaveFocus(); + }); + + it('a validation error keeps the editor focused', async () => { + const errorResource = new ErrorResource(TestBed.inject(Injector), {}, true, () => undefined, 'Error'); + vi.spyOn(errorResource, 'getInvolvedAttributes').mockReturnValue(['subject']); + vi.spyOn(errorResource, 'getMessagesPerAttribute').mockReturnValue({ subject: ['Required'] }); + const error = new HalError(new HttpErrorResponse({ status: 422 }), errorResource); + await mount({ editing: { save: () => Promise.reject(error) } }); + const handler = await openEditor(); + + await expect(handler.handleUserSubmit()).rejects.toBe(error); + + await waitFor(() => expect(within(harness.row('1')).getByRole('textbox')).toHaveFocus()); + }); + + it('no return when focus moved elsewhere while the save was pending', async () => { + const save = deferred<{ resource:WorkPackageResource; wasNew:boolean }>(); + await mount({ editing: { save: () => save.promise } }); + const handler = await openEditor(); + document.body.append(outside); + + const submission = handler.handleUserSubmit(); + outside.focus(); + await saveAs('Changed')().then(save.resolve); + await submission; + + expect(subjectCell()).not.toHaveFocus(); + }); + + it('a save completing while another row\'s editor is open does not return focus', async () => { + const save = deferred<{ resource:WorkPackageResource; wasNew:boolean }>(); + await mount({ editing: { save: () => save.promise } }); + const handler = await openEditor(); + + const submission = handler.handleUserSubmit(); + await openEditor('2'); + await saveAs('Changed')().then(save.resolve); + await submission; + + expect(within(harness.row('2')).getByRole('textbox')).toBeInTheDocument(); + expect(subjectCell()).not.toHaveFocus(); + }); + + describe('while the save is pending', () => { + const hierarchy:Partial = { + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + }; + const pendingSave = async (options:Partial = {}, id = '1') => { + const save = deferred<{ resource:WorkPackageResource; wasNew:boolean }>(); + await mount({ ...options, editing: { save: () => save.promise } }); + const handler = await openEditor(id); + const submission = handler.handleUserSubmit(); + return async () => { + await saveAs('Changed', id)().then(save.resolve); + await submission; + }; + }; + const moveByPointer = (target:HTMLElement) => { + fireEvent.mouseDown(target, { button: 0 }); + target.focus(); + fireEvent.mouseUp(target, { button: 0 }); + }; + + it('does not return focus when the user moved to another cell', async () => { + const complete = await pendingSave(); + moveByPointer(subjectCell('2')); + + await complete(); + + expect(subjectCell()).not.toHaveFocus(); + expect(subjectCell('2')).toHaveAttribute('tabindex', '0'); + expect(harness.table.keyboardNavigation!.position).toMatchObject({ key: wpOccurrenceKey('2'), cell: 1 }); + }); + + it('does not return focus when the user moved to another cell of the same row', async () => { + const complete = await pendingSave(); + moveByPointer(harness.row('1').querySelector('td.id')!); + + await complete(); + + expect(subjectCell()).not.toHaveFocus(); + expect(harness.row('1').querySelector('td.id')).toHaveAttribute('tabindex', '0'); + expect(harness.table.keyboardNavigation!.position).toMatchObject({ key: wpOccurrenceKey('1'), cell: 0 }); + }); + + it('returns focus to the hiding parent when the origin row was collapsed', async () => { + const complete = await pendingSave(hierarchy, '2'); + const hierarchies = harness.injector.get(WorkPackageViewHierarchiesService); + + hierarchies.collapse('1'); + await waitFor(() => expect(harness.row('2')).not.toBeVisible()); + await complete(); + + expect(harness.row('1')).toHaveFocus(); + expect(harness.table.scrollContainer.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + }); + + it('returns focus to the origin after a render moved the remembered position', async () => { + const complete = await pendingSave(hierarchy, '2'); + const hierarchies = harness.injector.get(WorkPackageViewHierarchiesService); + + hierarchies.collapse('1'); + await waitFor(() => expect(harness.row('2')).not.toBeVisible()); + expect(harness.table.keyboardNavigation!.position).toMatchObject({ key: wpOccurrenceKey('1'), cell: null }); + hierarchies.expand('1'); + await waitFor(() => expect(harness.row('2')).toBeVisible()); + await complete(); + + expect(subjectCell('2')).toHaveFocus(); + }); + }); + + it('saving a form whose second field opened by click returns focus to the first field\'s cell', async () => { + await mount({ + workPackages: [{ id: '1', attributes: { location: 'Here' } }, { id: '2' }], + columns: ['id', 'subject', 'location'], + editing: { fields: ['subject', 'location'], save: saveAs('Changed') }, + }); + const handler = await openEditor(); + const location = harness.row('1').querySelector('td.location .inline-edit--display-field')!; + fireEvent.mouseDown(location, { button: 0 }); + fireEvent.click(location); + await waitFor(() => expect(harness.table.editing.forms['1'].activeFields.location).toBeDefined()); + + await handler.handleUserSubmit(); + + expect(subjectCell()).toHaveAttribute('tabindex', '0'); + expect(harness.table.scrollContainer.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + expect(subjectCell()).toHaveFocus(); + }); + + it('reset with focus=false returns nothing', async () => { + await mount(); + await openEditor(); + + harness.table.editing.forms['1'].closeEditFields(); + + expect(document.body).toHaveFocus(); + }); + + it('flag off: reset still focuses the cell through FocusHelper', async () => { + await mount({}, false); + const focus = vi.spyOn(harness.injector.get(FocusHelperService), 'focus'); + const handler = await openEditor(); + + handler.handleUserCancel(); + + expect(focus).toHaveBeenCalledWith(subjectCell().querySelector('.inline-edit--container')); + expect(subjectCell()).not.toHaveFocus(); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts index 3fd6108ed8c7..eb48e3ee401e 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts @@ -51,6 +51,7 @@ import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorato import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { editFieldContainerClass } from 'core-app/shared/components/fields/display/display-field-renderer'; import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service'; +import { FocusPosition } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/focus-position'; export const activeFieldContainerClassName = 'inline-edit--active-field'; export const activeFieldClassName = 'inline-edit--field'; @@ -68,8 +69,13 @@ export class TableEditForm extends EditForm { @LazyInject() wpListService:WorkPackagesListService; + /** The focus position of the cell the first active field was opened in; null outside rendered occurrences. */ + public origin:FocusPosition|null = null; + + private submittedOrigin:FocusPosition|null = null; + // Use cell builder to reset edit fields - private cellBuilder = new CellBuilder(this.injector); + private cellBuilder = new CellBuilder(this.injector, this.table.configuration); // Subscription private resourceSubscription:Subscription = this @@ -109,10 +115,26 @@ export class TableEditForm extends EditForm { return this.rowContainer?.querySelector(`.${tdClassName}.${fieldName}`); } + public override async activate(fieldName:string, noWarnings = false):Promise { + try { + return await super.activate(fieldName, noWarnings); + } finally { + if (!this.hasActiveFields()) { + this.origin = null; + } + } + } + public activateField(form:EditForm, schema:IFieldSchema, fieldName:string, errors:string[]):Promise { return this.waitForContainer(fieldName) .then((cell) => { this.assertActivationActive(); + this.table.editing.forms[this.workPackageId] ??= this; + if (!this.hasActiveFields()) { + this.origin = this.originOf(fieldName); + } else if (this.origin) { + this.table.keyboardNavigation?.recordPosition(this.origin); + } // Forcibly set the width since the edit field may otherwise // be given more width. Thereby preserve a minimum width of 150. // To avoid flickering content, the padding is removed, too. @@ -155,9 +177,28 @@ export class TableEditForm extends EditForm { td.classList.remove(editModeClassName); if (focus) { - this.FocusHelper.focus(cell); + this.returnFocusFrom(cell); } } + + if (!this.hasActiveFields()) { + this.origin = null; + } + } + + public override async submit():Promise { + const origin = this.origin; + const resource = await super.submit(); + this.submittedOrigin = origin; + return resource; + } + + public override onSubmitComplete():void { + const origin = this.submittedOrigin; + this.submittedOrigin = null; + if (origin && !this.table.destroyed && !this.hasActiveFields()) { + this.table.keyboardNavigation?.returnFocus(origin); + } } public async requireVisible(fieldName:string):Promise { @@ -224,6 +265,29 @@ export class TableEditForm extends EditForm { }); } + private originOf(fieldName:string):FocusPosition|null { + const navigation = this.table.keyboardNavigation; + const td = this.findCell(fieldName); + if (!td || !navigation) { + return null; + } + + const origin = navigation.positionOfCell(td); + if (origin) { + navigation.recordPosition(origin); + } + return origin; + } + + private returnFocusFrom(container:HTMLElement):void { + const navigation = this.table.keyboardNavigation; + if (!navigation) { + this.FocusHelper.focus(container); + } else if (this.origin) { + navigation.returnFocus(this.origin); + } + } + private get rowContainer() { return this.table.tableAndTimelineContainer.querySelector(`.${this.classIdentifier}-table`); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/cell-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/cell-builder.ts index 08dadf22a0aa..602752a79d86 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/cell-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/cell-builder.ts @@ -33,6 +33,8 @@ import { editFieldContainerClass, } from 'core-app/shared/components/fields/display/display-field-renderer'; import { Injector } from '@angular/core'; +import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { keyboardNavigationEnabled, isNavigableColumn, navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; @@ -46,9 +48,10 @@ export class CellBuilder { @LazyInject(WorkPackageViewBaselineService) wpTableBaseline:WorkPackageViewBaselineService; - public fieldRenderer = new DisplayFieldRenderer(this.injector, 'table'); + public fieldRenderer:DisplayFieldRenderer; - constructor(public injector:Injector) { + constructor(public injector:Injector, private readonly configuration?:WorkPackageTableConfiguration) { + this.fieldRenderer = new DisplayFieldRenderer(this.injector, 'table', { tabbable: !keyboardNavigationEnabled(this.configuration) }); } public build(workPackage:WorkPackageResource, column:QueryColumn) { @@ -56,6 +59,10 @@ export class CellBuilder { const attribute = column.id; td.classList.add(tdClassName, attribute); + if (this.configuration && keyboardNavigationEnabled(this.configuration) && isNavigableColumn(column, this.configuration)) { + td.classList.add(navigableCellClassName); + } + if (attribute === 'subject') { td.classList.add('-max'); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-header-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-header-builder.ts index f4e90a65083d..279036376896 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-header-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-header-builder.ts @@ -34,6 +34,7 @@ import { GroupObject } from 'core-app/features/hal/resources/wp-collection-resou import { groupName } from './grouped-rows-helpers'; import { ProjectPhaseDisplayField } from 'core-app/shared/components/fields/display/field-types/project-phase-display-field.module'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; +import { navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; export function groupClassNameFor(group:GroupObject) { return `group-${group.identifier}`; @@ -44,7 +45,7 @@ export class GroupHeaderBuilder { public text:{ collapse:string, expand:string }; - constructor(public readonly injector:Injector) { + constructor(public readonly injector:Injector, private readonly keyboardNavigationEnabled = false) { this.text = { collapse: this.I18n.t('js.label_collapse'), expand: this.I18n.t('js.label_expand'), @@ -71,8 +72,12 @@ export class GroupHeaderBuilder { row.id = `wp-table-rowgroup-${group.index}`; row.dataset.groupIndex = (group.index).toString(); row.dataset.groupIdentifier = group.identifier; + const cellAttributes = this.keyboardNavigationEnabled ? ` class="-no-highlighting ${navigableCellClassName}" tabindex="-1"` : ' class="-no-highlighting"'; + if (this.keyboardNavigationEnabled) { + row.tabIndex = -1; + } row.innerHTML = ` - +
${escape(text)}
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts index 6de35ae54d35..254e2c2f3a92 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts @@ -25,6 +25,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { isNavigableColumn, keyboardNavigationEnabled, navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import { SingleRowBuilder } from 'core-app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; @@ -59,6 +60,10 @@ export class GroupSumsBuilder extends SingleRowBuilder { public renderColumns(sums:GroupObject['sums'], tr:HTMLTableRowElement) { this.augmentedColumns.forEach((column, i:number) => { const td = document.createElement('td'); + const { configuration } = this.workPackageTable; + if (keyboardNavigationEnabled(configuration) && isNavigableColumn(column, configuration)) { + td.classList.add(navigableCellClassName); + } const div = this.renderContent(sums, column.id, this.sumsSchema[column.id] as IFieldSchema); if (i === 0) { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts index a8feb8070ddf..ec7ce110ba8d 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts @@ -78,7 +78,7 @@ export class GroupedRenderPass extends PlainRenderPass { if (nextGroup && groupsChanged) { const groupClass = groupClassNameFor(nextGroup); const rowElement = this.headerBuilder.buildGroupRow(nextGroup, this.colspan); - this.appendNonWorkPackageRow(groupOccurrenceKey(nextGroup.index, 'header'), rowElement, groupClass, [groupRowClass]); + this.appendNonWorkPackageRow(groupOccurrenceKey(nextGroup.index, 'header'), rowElement, groupClass, [groupRowClass], false, nextGroup.identifier); currentGroup = nextGroup; } @@ -184,7 +184,7 @@ export class GroupedRenderPass extends PlainRenderPass { const { additionalClasses, hidden } = this.groupRowState(group); rowElement.classList.add(...additionalClasses); - this.appendNonWorkPackageRow(groupOccurrenceKey(group.index, 'sums'), rowElement, groupClass, additionalClasses, hidden); + this.appendNonWorkPackageRow(groupOccurrenceKey(group.index, 'sums'), rowElement, groupClass, additionalClasses, hidden, group.identifier); } private groupRowState(group:GroupObject):{ additionalClasses:string[], hidden:boolean } { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts index 867dbf7271fb..fcce395ec98e 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts @@ -44,6 +44,7 @@ import type { RenderPassOptions } from '../../primary-render-pass'; import { GroupedRenderPass } from './grouped-render-pass'; import { groupedRowClassName, groupIdentifier } from './grouped-rows-helpers'; import type { OccurrenceKey } from '../../../rendered-occurrence-ledger'; +import { keyboardNavigationEnabled } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; export class GroupedRowsBuilder extends RowsBuilder { // Injections @@ -81,7 +82,7 @@ export class GroupedRowsBuilder extends RowsBuilder { } public buildRows(options:RenderPassOptions) { - const builder = new GroupHeaderBuilder(this.injector); + const builder = new GroupHeaderBuilder(this.injector, keyboardNavigationEnabled(this.workPackageTable.configuration)); return new GroupedRenderPass( this.injector, this.workPackageTable, @@ -94,9 +95,9 @@ export class GroupedRowsBuilder extends RowsBuilder { /** * Refresh the group expansion state */ - public refreshExpansionState() { + public refreshExpansionState():ReadonlyMap { const groups = this.getGroupData(); - const builder = new GroupHeaderBuilder(this.injector); + const builder = new GroupHeaderBuilder(this.injector, keyboardNavigationEnabled(this.workPackageTable.configuration)); const updates = new Map(); this.workPackageTable.tableAndTimelineContainer @@ -137,7 +138,7 @@ export class GroupedRowsBuilder extends RowsBuilder { }); }); - this.workPackageTable.setHidden(updates); + return updates; } /** diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts index a294c7af1892..dcbc13472ba1 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts @@ -26,6 +26,7 @@ //++ import { fireEvent, waitFor, within } from '@testing-library/dom'; +import { groupOccurrenceKey } from '../../../rendered-occurrence-ledger'; import { buildTable, TableHarness } from '../../../testing/table-harness'; const newStatus = { href: '/api/v3/statuses/1' }; @@ -119,4 +120,12 @@ describe('Grouped table sums rows', () => { expect(sumsTimelineRow(0)).toBeVisible(); expect(sumsTimelineRow(1)).toBeVisible(); }); + + it('tags group header and sums occurrences with the group identifier', () => { + const identifier = harness.groupHeader(1).dataset.groupIdentifier; + + expect(identifier).toBeTruthy(); + expect(harness.table.ledger.byKey(groupOccurrenceKey(1, 'header'))?.groupIdentifier).toBe(identifier); + expect(harness.table.ledger.byKey(groupOccurrenceKey(1, 'sums'))?.groupIdentifier).toBe(identifier); + }); }); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts index 0107ff996f25..8af7a13e717a 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts @@ -38,6 +38,7 @@ import { } from 'core-app/features/work-packages/components/wp-fast-table/helpers/wp-table-hierarchy-helpers'; import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; +import { keyboardNavigationEnabled } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; export const indicatorCollapsedClass = '-hierarchy-collapsed'; export const hierarchyCellClassName = 'wp-table--hierarchy-span'; @@ -172,6 +173,7 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { private buildHierarchyIndicator(workPackage:WorkPackageResource, row:HTMLTableRowElement, level:number):HTMLElement { const hierarchyIndicator = document.createElement('span'); const collapsed = this.wpTableHierarchies.collapsed(workPackage.id!); + const tabindex = keyboardNavigationEnabled(this.workPackageTable.configuration) ? '-1' : '0'; const indicatorWidth = `${hierarchyBaseIndentation + (hierarchyIndentation * level)}px`; hierarchyIndicator.classList.add(hierarchyCellClassName); hierarchyIndicator.style.width = indicatorWidth; @@ -180,7 +182,7 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { if (this.parentsWithVisibleChildren[workPackage.id!]) { const className = collapsed ? indicatorCollapsedClass : ''; hierarchyIndicator.innerHTML = ` - + ${this.text.expanded( level, @@ -192,7 +194,7 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { `; } else { hierarchyIndicator.innerHTML = ` - + ${this.text.leaf(level)} `; diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts index 1daffbc8d4b8..01c9d34e3509 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts @@ -245,6 +245,7 @@ workPackage:WorkPackageResource, classIdentifier:string, additionalClasses:string[] = [], hidden = false, + groupIdentifier?:string, ) { row.classList.add(classIdentifier); this.registerAppended(row, { @@ -255,6 +256,7 @@ workPackage:WorkPackageResource, workPackageId: null, renderType: 'primary', hidden, + groupIdentifier, }); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relation-cell-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relation-cell-builder.ts index 209e5f90de17..9e9f0cfc5d73 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relation-cell-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relation-cell-builder.ts @@ -35,6 +35,8 @@ import { States } from 'core-app/core/states/states.service'; import { WorkPackageRelationsService } from '../../wp-relations/wp-relations.service'; import { QueryColumn, queryColumnTypes } from '../../wp-query/query-column'; import { tdClassName } from './cell-builder'; +import { keyboardNavigationEnabled, navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; +import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; export const relationCellTdClassName = 'wp-table--relation-cell-td'; export const relationCellIndicatorClassName = 'wp-table--relation-indicator'; @@ -46,7 +48,7 @@ export class RelationCellbuilder { @LazyInject() wpTableRelationColumns:WorkPackageViewRelationColumnsService; - constructor(public readonly injector:Injector) { + constructor(public readonly injector:Injector, private readonly configuration?:WorkPackageTableConfiguration) { } public build(workPackage:WorkPackageResource, column:QueryColumn) { @@ -54,6 +56,10 @@ export class RelationCellbuilder { td.classList.add(tdClassName, relationCellTdClassName, column.id); td.dataset.columnId = column.id; + if (keyboardNavigationEnabled(this.configuration)) { + td.classList.add(navigableCellClassName); + } + // Get current expansion and value state const expanded = this.wpTableRelationColumns.getExpandFor(workPackage.id!) === column.id; const relationState = this.wpRelations.state(workPackage.id!).value; @@ -90,7 +96,7 @@ export class RelationCellbuilder { const indicator = document.createElement('span'); indicator.classList.add(relationCellIndicatorClassName); indicator.setAttribute('aria-hidden', 'true'); - indicator.setAttribute('tabindex', '0'); + indicator.setAttribute('tabindex', keyboardNavigationEnabled(this.configuration) ? '-1' : '0'); return indicator; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts index b6b2b379b4bb..7673a9ae0626 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts @@ -32,6 +32,8 @@ import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorato import { States } from 'core-app/core/states/states.service'; import { commonRowClassName, SingleRowBuilder, tableRowClassName } from '../rows/single-row-builder'; import { tdClassName } from '../cell-builder'; +import { keyboardNavigationEnabled, navigableCellClassName } from '../../handlers/state/keyboard/roving-tabindex'; +import { removeTabStops } from 'core-app/shared/helpers/dom/remove-tab-stops'; import { WorkPackageTable } from '../../wp-fast-table'; import { isRelationColumn, QueryColumn } from '../../../wp-query/query-column'; @@ -94,6 +96,9 @@ public readonly injector:Injector, const tr = document.createElement('tr'); tr.dataset.workPackageId = to.id!; tr.dataset.classIdentifier = identifier; + if (keyboardNavigationEnabled(this.workPackageTable.configuration)) { + tr.setAttribute('tabindex', '-1'); + } tr.classList.add( commonRowClassName, @@ -128,12 +133,19 @@ relationGroupClass(from.id!), relationLabel.textContent = typeLabel; row.querySelector(`.${relationCellClassName}`)!.innerHTML = ''; - row.querySelector(`.${relationCellClassName}.${columnId}`)!.append(relationLabel); + const labelCell = row.querySelector(`.${relationCellClassName}.${columnId}`)!; + labelCell.append(relationLabel); + if (keyboardNavigationEnabled(this.workPackageTable.configuration)) { + removeTabStops(labelCell); + } } protected emptyRelationCell(column:QueryColumn) { const cell = document.createElement('td'); cell.classList.add(relationCellClassName, tdClassName, column.id); + if (keyboardNavigationEnabled(this.workPackageTable.configuration)) { + cell.classList.add(navigableCellClassName); + } return cell; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts index d3f2abed7c8e..68c15664df9e 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts @@ -25,6 +25,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { isNavigableColumn, keyboardNavigationEnabled, navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; import { Injector } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { locateTableRowByIdentifier } from 'core-app/features/work-packages/components/wp-fast-table/helpers/wp-table-row-helpers'; @@ -74,16 +75,16 @@ export class SingleRowBuilder { @LazyInject() I18n!:I18nService; // Cell builder instance - protected cellBuilder = new CellBuilder(this.injector); + protected cellBuilder = new CellBuilder(this.injector, this.workPackageTable.configuration); // Relation cell builder instance - protected relationCellBuilder = new RelationCellbuilder(this.injector); + protected relationCellBuilder = new RelationCellbuilder(this.injector, this.workPackageTable.configuration); // Share cell builder instance - protected shareCellBuilder = new ShareCellbuilder(this.injector); + protected shareCellBuilder = new ShareCellbuilder(this.injector, this.workPackageTable.configuration); // Details Link builder - protected contextLinkBuilder = new TableActionRenderer(this.injector); + protected contextLinkBuilder = new TableActionRenderer(this.injector, this.workPackageTable.configuration); // Baseline column builder protected baselineColumnBuilder = new BaselineColumnBuilder(this.injector); @@ -141,6 +142,10 @@ export class SingleRowBuilder { if (this.workPackageTable.configuration.columnMenuEnabled) { const td = document.createElement('td'); td.classList.add('hide-when-print'); + if (keyboardNavigationEnabled(this.workPackageTable.configuration) + && isNavigableColumn(column, this.workPackageTable.configuration)) { + td.classList.add(navigableCellClassName); + } return td; } return null; @@ -169,7 +174,7 @@ export class SingleRowBuilder { public createEmptyRow(workPackage:WorkPackageResource) { const identifier = this.classIdentifier(workPackage); const tr = document.createElement('tr'); - tr.setAttribute('tabindex', '0'); + tr.setAttribute('tabindex', keyboardNavigationEnabled(this.workPackageTable.configuration) ? '-1' : '0'); tr.dataset.workPackageId = workPackage.id!; tr.dataset.classIdentifier = identifier; tr.classList.add( diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/share-cell-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/share-cell-builder.ts index a53f787554a0..521f993e2fc8 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/share-cell-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/share-cell-builder.ts @@ -29,6 +29,9 @@ import { Injector } from '@angular/core'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { QueryColumn } from '../../wp-query/query-column'; import { tdClassName } from './cell-builder'; +import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { keyboardNavigationEnabled, navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; +import { removeTabStops } from 'core-app/shared/helpers/dom/remove-tab-stops'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; import { PrincipalRendererService } from 'core-app/shared/components/principal/principal-renderer.service'; @@ -41,7 +44,7 @@ export class ShareCellbuilder { @LazyInject(I18nService) I18n:I18nService; - constructor(public readonly injector:Injector) { + constructor(public readonly injector:Injector, private readonly configuration?:WorkPackageTableConfiguration) { } public build(workPackage:WorkPackageResource, column:QueryColumn) { @@ -49,6 +52,11 @@ export class ShareCellbuilder { td.classList.add(tdClassName, column.id); td.dataset.columnId = column.id; + const navigationEnabled = keyboardNavigationEnabled(this.configuration); + if (navigationEnabled) { + td.classList.add(navigableCellClassName); + } + const relevantShares = this .isolatedQuerySpace .workPackageSharesCache @@ -68,6 +76,10 @@ export class ShareCellbuilder { td.setAttribute('title', this.I18n.t('js.work_packages.sharing.show_all_users')); } + if (navigationEnabled) { + removeTabStops(td); + } + return td; } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts index df57eae683d5..5b7e781bf928 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts @@ -32,12 +32,15 @@ import { contextMenuSpanClassName, contextMenuTdClassName } from 'core-app/featu import { internalContextMenuColumn } from 'core-app/features/work-packages/components/wp-fast-table/builders/internal-sort-columns'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import { tdClassName } from './cell-builder'; +import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { keyboardNavigationEnabled, navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; +import { removeTabStops } from 'core-app/shared/helpers/dom/remove-tab-stops'; export class TableActionRenderer { // Injections @LazyInject() tableActionsService:OpTableActionsService; - constructor(public readonly injector:Injector) { + constructor(public readonly injector:Injector, private readonly configuration?:WorkPackageTableConfiguration) { } public build(workPackage:WorkPackageResource):HTMLTableCellElement{ @@ -56,6 +59,12 @@ export class TableActionRenderer { }); td.appendChild(span); + + if (keyboardNavigationEnabled(this.configuration)) { + td.classList.add(navigableCellClassName); + removeTabStops(td); + } + return td; } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts index a9d391249715..b22598b164e0 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts @@ -36,7 +36,6 @@ import { HalResourceEditingService } from 'core-app/shared/components/fields/edi import { getPosition } from 'core-app/shared/helpers/set-click-position/set-click-position'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import { EditActivationCancelled } from 'core-app/shared/components/fields/edit/edit-form/edit-activation-cancelled'; -import { States } from 'core-app/core/states/states.service'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { TableEventComponent, TableEventHandler } from '../table-handler-registry'; import { ClickOrEnterHandler } from '../click-or-enter-handler'; @@ -47,8 +46,6 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service export class EditCellHandler extends ClickOrEnterHandler implements TableEventHandler { // Injections - @LazyInject() public states:States; - @LazyInject() public halEditing:HalResourceEditingService; @LazyInject() public configurationService:ConfigurationService; @@ -113,36 +110,40 @@ export class EditCellHandler extends ClickOrEnterHandler implements TableEventHa return; } - // Locate the row - const rowElement = target.closest(`.${tableRowClassName}`)!; - // Get the work package we're editing - const workPackageId = rowElement.dataset.workPackageId!; - const workPackage = this.states.workPackages.get(workPackageId).value!; - // Get the row context - const classIdentifier = rowElement.dataset.classIdentifier!; - - // Get any existing edit state for this work package - const form = table.editing.startEditing(workPackage, classIdentifier); - let positionOffset = 0; if (evt instanceof MouseEvent) { // Get the position where the user clicked. positionOffset = getPosition(evt); } - // Activate the field - form.activate(fieldName) - .then((handler) => { - if (table.destroyed) return; - if (!handler) { - target.classList.add(readOnlyClassName); - return; - } - handler.$onUserActivate.next(); - handler.focus(positionOffset); - }) - .catch((error:unknown) => { - if (!(error instanceof EditActivationCancelled) && !table.destroyed) target.classList.add(readOnlyClassName); - }); + activateTableField(table, target, positionOffset); } } + +/** Opens the inline editor of an editable display field in a table row; false when the field has no name. */ +export function activateTableField(table:WorkPackageTable, target:HTMLElement, positionOffset = 0):boolean { + const fieldName = target.dataset.fieldName; + if (!fieldName) { + return false; + } + + const rowElement = target.closest(`.${tableRowClassName}`)!; + const workPackage = table.states.workPackages.get(rowElement.dataset.workPackageId!).value!; + const form = table.editing.startEditing(workPackage, rowElement.dataset.classIdentifier!); + + form.activate(fieldName) + .then((handler) => { + if (table.destroyed) return; + if (!handler) { + target.classList.add(readOnlyClassName); + return; + } + handler.$onUserActivate.next(); + handler.focus(positionOffset); + }) + .catch((error:unknown) => { + if (!(error instanceof EditActivationCancelled) && !table.destroyed) target.classList.add(readOnlyClassName); + }); + + return true; +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts index f34857b88868..0f90ea7f683f 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts @@ -28,7 +28,10 @@ import { Injector } from '@angular/core'; import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service'; import { States } from 'core-app/core/states/states.service'; -import { WorkPackageViewSelectionGesturesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection-gestures.service'; +import { + SelectionModifiers, + WorkPackageViewSelectionGesturesService, +} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection-gestures.service'; import { displayClassName } from 'core-app/shared/components/fields/display/display-field-renderer'; import { activeFieldClassName } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; @@ -43,16 +46,8 @@ import { resolveRoutingId } from 'core-app/features/work-packages/helpers/work-p export class RowClickHandler implements TableEventHandler { // Injections - @LazyInject() public states:States; - @LazyInject() public keepTab:KeepTabService; - @LazyInject() public selectionGestures:WorkPackageViewSelectionGesturesService; - - @LazyInject() public wpTableFocus:WorkPackageViewFocusService; - - @LazyInject() public urlParams:UrlParamsService; - @LazyInject() public configurationService:ConfigurationService; constructor(public readonly injector:Injector) { @@ -93,18 +88,7 @@ export class RowClickHandler implements TableEventHandler { return true; } - this.selectionGestures.handleClick(wpId, view.workPackageTable.renderedRows, evt, element.dataset.classIdentifier); - - if (!(evt.ctrlKey || evt.metaKey || evt.shiftKey)) { - view.itemClicked.emit({ workPackageId: wpId, double: false }); - } - - // The current row is the last selected work package - // not matter what other rows are (de-)selected below. - // Thus save that row for the details view button. - this.wpTableFocus.updateFocus(wpId); - - this.switchOpenSplitViewTo(wpId); + activateRow(view, wpId, element, evt); return false; } @@ -113,26 +97,55 @@ export class RowClickHandler implements TableEventHandler { return target.classList.contains(displayClassName) && !this.configurationService.requireDoubleClickForInlineEdit(); } +} - /** - * If a split view is currently open (URL has a /details/:id(/:tab) suffix), switch it - * to the clicked row's work package. List and split view are separate, independently - * bootstrapped Angular elements (each with their own isolated query space), so they - * don't share WorkPackageViewFocusService - this can't be done by reacting to - * updateFocus() from within the split view, it has to be driven from here. - */ - private switchOpenSplitViewTo(wpId:string):void { - const details = this.urlParams.currentDetailsRouteParams(); - if (!details) { - return; - } +/** + * Runs the effects of activating a row: updates the selection (plain + * activation replaces it), emits `itemClicked` without modifiers, makes the + * row the current work package and follows it in an open split view. + */ +export function activateRow( + view:TableEventComponent, + wpId:string, + element:HTMLTableRowElement, + modifiers:SelectionModifiers = {}, +):void { + const { injector } = view.workPackageTable; + + injector.get(WorkPackageViewSelectionGesturesService) + .handleClick(wpId, view.workPackageTable.renderedRows, modifiers, element.dataset.classIdentifier); + + if (!(modifiers.ctrlKey || modifiers.metaKey || modifiers.shiftKey)) { + view.itemClicked.emit({ workPackageId: wpId, double: false }); + } - const newRoutingId = resolveRoutingId(this.states, wpId); - if (details.routingId === newRoutingId) { - return; - } + // The current row is the last selected work package + // not matter what other rows are (de-)selected below. + // Thus save that row for the details view button. + injector.get(WorkPackageViewFocusService).updateFocus(wpId); + + switchOpenSplitViewTo(injector, wpId); +} + +/** + * If a split view is currently open (URL has a /details/:id(/:tab) suffix), switch it + * to the clicked row's work package. List and split view are separate, independently + * bootstrapped Angular elements (each with their own isolated query space), so they + * don't share WorkPackageViewFocusService - this can't be done by reacting to + * updateFocus() from within the split view, it has to be driven from here. + */ +function switchOpenSplitViewTo(injector:Injector, wpId:string):void { + const urlParams = injector.get(UrlParamsService); + const details = urlParams.currentDetailsRouteParams(); + if (!details) { + return; + } - const newPath = `${this.urlParams.basePathWithoutDetails()}/details/${newRoutingId}${details.tab ? `/${details.tab}` : ''}${window.location.search}`; - Turbo.visit(newPath, { frame: 'content-bodyRight', action: 'advance' }); + const newRoutingId = resolveRoutingId(injector.get(States), wpId); + if (details.routingId === newRoutingId) { + return; } + + const newPath = `${urlParams.basePathWithoutDetails()}/details/${newRoutingId}${details.tab ? `/${details.tab}` : ''}${window.location.search}`; + Turbo.visit(newPath, { frame: 'content-bodyRight', action: 'advance' }); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/focus-position.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/focus-position.spec.ts new file mode 100644 index 000000000000..8150f2fb27b1 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/focus-position.spec.ts @@ -0,0 +1,222 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + FocusPosition, + initialPosition, + NavigableRow, + computeNextPosition, + reconcilePosition, +} from './focus-position'; + +function buildRows():NavigableRow[] { + return [ + { key: 'header', kind: 'header', hidden: false, cellCount: 3, expanded: null }, + { key: 'A', kind: 'workPackage', hidden: false, cellCount: 3, expanded: null }, + { key: '0:header', kind: 'groupHeader', hidden: false, cellCount: 1, expanded: true, groupIdentifier: 'g' }, + { key: 'B', kind: 'workPackage', hidden: true, cellCount: 3, expanded: null }, + { key: 'C', kind: 'workPackage', hidden: false, cellCount: 3, expanded: false }, + { key: '0:sums', kind: 'sums', hidden: false, cellCount: 3, expanded: null, groupIdentifier: 'g' }, + { key: 'totals', kind: 'totals', hidden: false, cellCount: 3, expanded: null }, + ]; +} + +function groupOf(key:string) { + return ['0:header', '0:sums'].includes(key) ? { groupIdentifier: 'g' } : {}; +} + +function row(key:string):FocusPosition { + return { key, cell: null, desiredColumn: null, ...groupOf(key) }; +} + +function cell(key:string, index:number, desiredColumn:number = index):FocusPosition { + return { key, cell: index, desiredColumn, ...groupOf(key) }; +} + +function move(position:FocusPosition) { + return { type: 'move', position }; +} + +describe('focus position model', () => { + it('ArrowDown in row focus skips hidden rows', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, row('A'), 'ArrowDown', 5)).toEqual(move(row('0:header'))); + expect(computeNextPosition(rows, row('0:header'), 'ArrowDown', 5)).toEqual(move(row('C'))); + }); + + it('ArrowUp from first body row lands on the header row', () => { + expect(computeNextPosition(buildRows(), row('A'), 'ArrowUp', 5)).toEqual(move(row('header'))); + }); + + it('ArrowUp and ArrowDown at the edges are none', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, row('header'), 'ArrowUp', 5)).toEqual({ type: 'none' }); + expect(computeNextPosition(rows, row('totals'), 'ArrowDown', 5)).toEqual({ type: 'none' }); + }); + + it('Home/End go to first/last visible row in row focus', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, row('C'), 'Home', 5)).toEqual(move(row('header'))); + expect(computeNextPosition(rows, row('C'), 'End', 5)).toEqual(move(row('totals'))); + }); + + it('Home/End in cell focus go to the first/last cell of the row', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, cell('A', 1), 'Home', 5)).toEqual(move(cell('A', 0))); + expect(computeNextPosition(rows, cell('A', 1), 'End', 5)).toEqual(move(cell('A', 2))); + }); + + it('ArrowRight on an expanded or leaf row enters cell 0', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, row('A'), 'ArrowRight', 5)).toEqual(move(cell('A', 0))); + expect(computeNextPosition(rows, row('0:header'), 'ArrowRight', 5)).toEqual(move(cell('0:header', 0))); + }); + + it('ArrowRight on a collapsed parent returns expand', () => { + expect(computeNextPosition(buildRows(), row('C'), 'ArrowRight', 5)).toEqual({ type: 'expand', key: 'C' }); + }); + + it('ArrowLeft on an expanded group header returns collapse', () => { + expect(computeNextPosition(buildRows(), row('0:header'), 'ArrowLeft', 5)).toEqual({ type: 'collapse', key: '0:header' }); + }); + + it('ArrowLeft on a non-expanded row is none', () => { + expect(computeNextPosition(buildRows(), row('A'), 'ArrowLeft', 5)).toEqual({ type: 'none' }); + }); + + it('ArrowLeft on cell 0 returns to the row', () => { + expect(computeNextPosition(buildRows(), cell('A', 0), 'ArrowLeft', 5)) + .toEqual(move({ key: 'A', cell: null, desiredColumn: 0 })); + }); + + it('ArrowRight on the last cell is none', () => { + expect(computeNextPosition(buildRows(), cell('A', 2), 'ArrowRight', 5)).toEqual({ type: 'none' }); + }); + + it('ArrowDown keeps the desired column and clamps on a shorter row', () => { + const rows = buildRows(); + + const toGroup = computeNextPosition(rows, cell('A', 2), 'ArrowDown', 5); + expect(toGroup).toEqual(move({ ...cell('0:header', 0, 2) })); + + const toC = computeNextPosition(rows, (toGroup as { position:FocusPosition }).position, 'ArrowDown', 5); + expect(toC).toEqual(move({ key: 'C', cell: 2, desiredColumn: 2 })); + }); + + it('ArrowLeft/Right set the desired column to the landing cell', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, cell('A', 0, 2), 'ArrowRight', 5)).toEqual(move(cell('A', 1))); + expect(computeNextPosition(rows, cell('A', 2, 0), 'ArrowLeft', 5)).toEqual(move(cell('A', 1))); + }); + + it('CtrlHome/CtrlEnd go to the grid corners in cell focus and first/last row in row focus', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, cell('C', 1), 'CtrlHome', 5)).toEqual(move(cell('header', 0))); + expect(computeNextPosition(rows, cell('C', 1), 'CtrlEnd', 5)).toEqual(move(cell('totals', 2))); + expect(computeNextPosition(rows, row('C'), 'CtrlHome', 5)).toEqual(move(row('header'))); + expect(computeNextPosition(rows, row('C'), 'CtrlEnd', 5)).toEqual(move(row('totals'))); + }); + + it('PageDown moves pageSize visible rows and stops at the end', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, row('header'), 'PageDown', 2)).toEqual(move(row('0:header'))); + expect(computeNextPosition(rows, row('0:header'), 'PageDown', 10)).toEqual(move(row('totals'))); + expect(computeNextPosition(rows, row('totals'), 'PageDown', 2)).toEqual({ type: 'none' }); + }); + + it('PageUp moves pageSize visible rows and stops at the start', () => { + const rows = buildRows(); + + expect(computeNextPosition(rows, row('0:sums'), 'PageUp', 2)).toEqual(move(row('0:header'))); + expect(computeNextPosition(rows, row('A'), 'PageUp', 10)).toEqual(move(row('header'))); + }); + + it('PageDown in cell focus keeps cell focus with the desired column', () => { + expect(computeNextPosition(buildRows(), cell('A', 2), 'PageDown', 2)) + .toEqual(move({ key: 'C', cell: 2, desiredColumn: 2 })); + }); + + it('initialPosition prefers the first visible work package row', () => { + const rows = buildRows(); + + expect(initialPosition(rows)).toEqual(row('A')); + expect(initialPosition(rows.filter((r) => r.kind !== 'workPackage'))).toEqual(row('header')); + expect(initialPosition(rows.map((r) => ({ ...r, hidden: true })))).toBeNull(); + }); + + it('reconcilePosition keeps the same row, else the row that hides it, else the nearest visible row, else null', () => { + const rows = buildRows(); + + expect(reconcilePosition(rows, cell('A', 2), 1)).toEqual(cell('A', 2)); + expect(reconcilePosition(rows, cell('0:header', 2), 2)).toEqual(cell('0:header', 0, 2)); + expect(reconcilePosition(rows, cell('B', 1), 3)).toEqual(row('0:header')); + expect(reconcilePosition(rows, row('gone'), 40)).toEqual(row('totals')); + expect(reconcilePosition(rows.map((r) => ({ ...r, hidden: true })), row('A'), 1)).toBeNull(); + }); + + it('reconcilePosition keeps a sums position on the sums row, not the header, after rebinding', () => { + const rows = buildRows().map((r) => (r.groupIdentifier ? { ...r, key: r.key.replace('0:', '1:') } : r)); + + expect(reconcilePosition(rows, cell('0:sums', 1), 5)).toEqual({ ...cell('0:sums', 1), key: '1:sums' }); + }); + + it('reconcilePosition rebinds a group header by identifier when its ordinal key changed', () => { + const rows = buildRows().map((r) => (r.key === '0:header' ? { ...r, key: '1:header' } : r)); + + expect(reconcilePosition(rows, row('0:header'), 2)).toEqual({ ...row('0:header'), key: '1:header' }); + }); + + it('reconcilePosition does not rebind a removed group to another group at the same ordinal', () => { + const rows = buildRows().filter((r) => !r.groupIdentifier); + rows.splice(rows.length - 1, 0, { key: '0:header', kind: 'groupHeader', hidden: false, cellCount: 1, expanded: true, groupIdentifier: 'h' }); + + expect(reconcilePosition(rows, row('0:header'), 2)).toEqual(row('A')); + }); + + it('reconcilePosition falls back to row focus when the row has no cells', () => { + const rows = buildRows().map((r) => (r.key === 'A' ? { ...r, cellCount: 0 } : r)); + + expect(reconcilePosition(rows, cell('A', 1), 1)).toEqual({ key: 'A', cell: null, desiredColumn: 1 }); + }); + + it('computeNextPosition on a hidden current row is none for every key', () => { + const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'CtrlHome', 'CtrlEnd'] as const; + + keys.forEach((key) => { + expect(computeNextPosition(buildRows(), row('B'), key, 5)).toEqual({ type: 'none' }); + expect(computeNextPosition(buildRows(), cell('B', 1), key, 5)).toEqual({ type: 'none' }); + }); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/focus-position.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/focus-position.ts new file mode 100644 index 000000000000..b08dac59e4ec --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/focus-position.ts @@ -0,0 +1,253 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +export type RowKind = 'header'|'workPackage'|'groupHeader'|'sums'|'totals'; + +export interface NavigableRow { + key:string; + kind:RowKind; + hidden:boolean; + cellCount:number; + expanded:boolean|null; + groupIdentifier?:string; +} + +export interface FocusPosition { + key:string; + cell:number|null; + desiredColumn:number|null; + groupIdentifier?:string; +} + +export type NavKey = 'ArrowUp'|'ArrowDown'|'ArrowLeft'|'ArrowRight'|'Home'|'End'|'PageUp'|'PageDown'|'CtrlHome'|'CtrlEnd'; + +export type NavResult = + | { type:'move', position:FocusPosition } + | { type:'expand', key:string } + | { type:'collapse', key:string } + | { type:'none' }; + +const NONE:NavResult = { type: 'none' }; + +function positionOn(row:NavigableRow, cell:number|null, desiredColumn:number|null):FocusPosition { + const position:FocusPosition = { key: row.key, cell, desiredColumn }; + return row.groupIdentifier === undefined ? position : { ...position, groupIdentifier: row.groupIdentifier }; +} + +function visibleIndexes(rows:readonly NavigableRow[], requireCells:boolean):number[] { + return rows.flatMap((row, index) => (row.hidden || (requireCells && row.cellCount === 0) ? [] : [index])); +} + +function landOn(row:NavigableRow, cellFocus:boolean, desiredColumn:number|null):FocusPosition { + if (!cellFocus) { + return positionOn(row, null, null); + } + + const column = desiredColumn ?? 0; + return positionOn(row, Math.min(column, row.cellCount - 1), column); +} + +function toResult(position:FocusPosition, current:FocusPosition):NavResult { + const unchanged = position.key === current.key + && position.cell === current.cell + && position.desiredColumn === current.desiredColumn; + return unchanged ? NONE : { type: 'move', position }; +} + +function moveRows( + rows:readonly NavigableRow[], + position:FocusPosition, + currentIndex:number, + delta:number, +):NavResult { + const cellFocus = position.cell !== null; + const visible = visibleIndexes(rows, cellFocus); + const at = visible.indexOf(currentIndex); + const target = visible[Math.min(Math.max(at + delta, 0), visible.length - 1)]; + + if (at === -1 || target === currentIndex) { + return NONE; + } + + return toResult(landOn(rows[target], cellFocus, position.desiredColumn), position); +} + +function moveToEdge( + rows:readonly NavigableRow[], + position:FocusPosition, + edge:'first'|'last', + column:'desired'|'edge', +):NavResult { + const cellFocus = position.cell !== null; + const visible = visibleIndexes(rows, cellFocus); + const target = rows[edge === 'first' ? visible[0] : visible[visible.length - 1]]; + + if (!target) { + return NONE; + } + + if (!cellFocus || column === 'desired') { + return toResult(landOn(target, cellFocus, position.desiredColumn), position); + } + + const cell = edge === 'first' ? 0 : target.cellCount - 1; + return toResult(positionOn(target, cell, cell), position); +} + +function navigateHorizontally(row:NavigableRow, position:FocusPosition, direction:'Home'|'End'|'ArrowLeft'|'ArrowRight'):NavResult { + const cell = position.cell ?? 0; + const last = row.cellCount - 1; + const landing = { Home: 0, End: last, ArrowLeft: cell - 1, ArrowRight: cell + 1 }[direction]; + + if (landing < 0) { + return toResult(positionOn(row, null, position.desiredColumn), position); + } + + if (landing > last) { + return NONE; + } + + return toResult(positionOn(row, landing, landing), position); +} + +function navigateFromRow(rows:readonly NavigableRow[], row:NavigableRow, position:FocusPosition, key:NavKey, index:number, pageSize:number):NavResult { + switch (key) { + case 'ArrowRight': + if (row.expanded === false) { + return { type: 'expand', key: row.key }; + } + return row.cellCount > 0 ? { type: 'move', position: positionOn(row, 0, 0) } : NONE; + case 'ArrowLeft': + return row.expanded === true ? { type: 'collapse', key: row.key } : NONE; + default: + return navigateVertically(rows, position, key, index, pageSize); + } +} + +function navigateVertically(rows:readonly NavigableRow[], position:FocusPosition, key:NavKey, index:number, pageSize:number):NavResult { + switch (key) { + case 'ArrowUp': + return moveRows(rows, position, index, -1); + case 'ArrowDown': + return moveRows(rows, position, index, 1); + case 'PageUp': + return moveRows(rows, position, index, -pageSize); + case 'PageDown': + return moveRows(rows, position, index, pageSize); + case 'Home': + return moveToEdge(rows, position, 'first', 'desired'); + case 'End': + return moveToEdge(rows, position, 'last', 'desired'); + case 'CtrlHome': + return moveToEdge(rows, position, 'first', 'edge'); + case 'CtrlEnd': + return moveToEdge(rows, position, 'last', 'edge'); + default: + return NONE; + } +} + +/** Computes what a navigation key does at the given position. */ +export function computeNextPosition( + rows:readonly NavigableRow[], + position:FocusPosition, + key:NavKey, + pageSize:number, +):NavResult { + const index = rows.findIndex((row) => row.key === position.key); + if (index === -1) { + return NONE; + } + + const row = rows[index]; + if (row.hidden) { + return NONE; + } + + if (position.cell === null) { + return navigateFromRow(rows, row, position, key, index, pageSize); + } + + switch (key) { + case 'ArrowLeft': + case 'ArrowRight': + case 'Home': + case 'End': + return navigateHorizontally(row, position, key); + default: + return navigateVertically(rows, position, key, index, pageSize); + } +} + +/** Returns the position focus starts on after a render, or null when no row is visible. */ +export function initialPosition(rows:readonly NavigableRow[]):FocusPosition|null { + const visible = rows.filter((row) => !row.hidden); + const first = visible.find((row) => row.kind === 'workPackage') ?? visible[0]; + return first ? positionOn(first, null, null) : null; +} + +function keyPart(key:string):string { + return key.slice(key.lastIndexOf(':') + 1); +} + +function isSameRow(row:NavigableRow, previous:FocusPosition):boolean { + const { groupIdentifier } = previous; + + if (groupIdentifier !== undefined) { + return row.groupIdentifier === groupIdentifier && keyPart(row.key) === keyPart(previous.key); + } + + return row.key === previous.key; +} + +/** Returns row focus on the visible row closest to the index, the lower one on a tie, or null when none is visible. */ +export function nearestPosition(rows:readonly NavigableRow[], index:number):FocusPosition|null { + const nearest = visibleIndexes(rows, false).reduce( + (best, candidate) => (best === null || Math.abs(candidate - index) < Math.abs(best - index) ? candidate : best), + null, + ); + + return nearest === null ? null : positionOn(rows[nearest], null, null); +} + +/** Keeps focus on the same row after a re-render, on the row that hides it when it is hidden, else on the nearest visible row. */ +export function reconcilePosition( + rows:readonly NavigableRow[], + previous:FocusPosition, + previousIndex:number, +):FocusPosition|null { + const index = rows.findIndex((row) => isSameRow(row, previous)); + const kept = rows[index]; + + if (kept && !kept.hidden) { + const cell = previous.cell === null || kept.cellCount === 0 ? null : Math.min(previous.cell, kept.cellCount - 1); + return positionOn(kept, cell, previous.desiredColumn); + } + + const owner = kept && rows.slice(0, index).reverse().find((row) => !row.hidden); + return owner ? positionOn(owner, null, null) : nearestPosition(rows, previousIndex); +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.spec.ts new file mode 100644 index 000000000000..53eb647a39eb --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.spec.ts @@ -0,0 +1,961 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { fireEvent, waitFor, within } from '@testing-library/dom'; +import { userEvent } from 'vitest/browser'; +import { States } from 'core-app/core/states/states.service'; +import { DisplayFieldService } from 'core-app/shared/components/fields/display/display-field.service'; +import { TextDisplayField } from 'core-app/shared/components/fields/display/field-types/text-display-field.module'; +import { WorkPackageViewCollapsedGroupsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service'; +import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; +import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; +import { WorkPackageContextMenuHelperService } from 'core-app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service'; +import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; +import { OpContextMenuHandler } from 'core-app/shared/components/op-context-menu/op-context-menu-handler'; +import { buildQuery, buildTable, TableHarness, TableHarnessOptions } from '../../../testing/table-harness'; +import { buildGroup } from '../../../testing/work-package-fixture'; +import { wpOccurrenceKey } from '../../../rendered-occurrence-ledger'; +import { KeyboardNavigation } from './keyboard-navigation-transformer'; +import { headerRowKey, totalsRowKey } from './navigable-rows'; +import { insideGrid, navigableCells } from './roving-tabindex'; + +class AnchorDisplayField extends TextDisplayField { + public render(element:HTMLElement, displayText:string):void { + const link = document.createElement('a'); + link.href = '#'; + link.textContent = displayText; + element.appendChild(link); + } +} + +const newStatus = { href: '/api/v3/statuses/1' }; +const inProgressStatus = { href: '/api/v3/statuses/2' }; + +const grouped:Partial = { + workPackages: [ + { id: '1', attributes: { status: newStatus } }, + { id: '2', attributes: { status: newStatus } }, + { id: '3', attributes: { status: inProgressStatus } }, + ], + groups: [ + { value: 'New', href: newStatus.href, count: 2 }, + { value: 'In progress', href: inProgressStatus.href, count: 1 }, + ], +}; + +describe('KeyboardNavigationTransformer', () => { + const harnesses:TableHarness[] = []; + + const mount = async (options:Partial = {}, beforeRender?:(table:TableHarness) => void) => { + const table = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }, { id: '3' }], + productionMarkup: true, + ...options, + configuration: { keyboardNavigationEnabled: true, ...options.configuration }, + }); + harnesses.push(table); + beforeRender?.(table); + await table.render(); + return table; + }; + + const press = (element:Element, key:string, init:KeyboardEventInit = {}) => fireEvent.keyDown(element, { key, ...init }); + + const pressActive = (key:string, init:KeyboardEventInit = {}) => press(document.activeElement!, key, init); + + const navigation = (table:TableHarness):KeyboardNavigation => table.table.keyboardNavigation!; + + const resource = (table:TableHarness, id:string) => table.injector.get(States).workPackages.get(id).value!; + + const cell = (row:HTMLTableRowElement, index:number) => navigableCells(row)[index]; + + const selectedIds = (table:TableHarness) => table.selection.getSelectedWorkPackageIds().sort(); + + const focusFromOutside = (element:HTMLElement) => { + const outside = document.createElement('button'); + document.body.append(outside); + try { + outside.focus(); + element.focus(); + } finally { + outside.remove(); + } + }; + + afterEach(async () => { + await Promise.all(harnesses.splice(0).map((table) => table.destroy())); + }); + + it('Tab from a control before the table lands on the first work package row', async () => { + const table = await mount(); + const before = document.createElement('button'); + before.textContent = 'Before'; + table.table.tableAndTimelineContainer.before(before); + try { + before.focus(); + + await userEvent.tab(); + + expect(table.row('1')).toHaveFocus(); + expect(table.row('1')).toHaveAttribute('tabindex', '0'); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: null, desiredColumn: null }); + } finally { + before.remove(); + } + }); + + it('Tab from a row leaves the grid even when a late control is tabbable', async () => { + const table = await mount(); + const late = document.createElement('span'); + late.setAttribute('tabindex', '0'); + table.row('1').querySelector('td.subject')!.appendChild(late); + table.row('1').focus(); + + await userEvent.tab(); + + expect(late).not.toHaveFocus(); + expect(insideGrid(table.table.scrollContainer, document.activeElement)).toBe(false); + }); + + it('Shift+Tab from a row sweeps late controls too', async () => { + const table = await mount(); + const late = document.createElement('span'); + late.setAttribute('tabindex', '0'); + table.row('2').querySelector('td.subject')!.appendChild(late); + table.row('2').focus(); + + await userEvent.tab({ shift: true }); + + expect(late).toHaveAttribute('tabindex', '-1'); + expect(late).not.toHaveFocus(); + }); + + it('a refresh while the inline-create button has focus leaves it there', async () => { + const table = await mount(); + table.row('1').focus(); + const button = within(table.container).getByRole('button', { name: 'Create new work package' }); + button.focus(); + + table.table.refreshRows(resource(table, '2')); + + await waitFor(() => expect(button).toHaveFocus()); + }); + + it('takes the scroll container and its wrapper out of the Tab order only when enabled', async () => { + const enabled = await mount(); + expect(enabled.table.scrollContainer).toHaveAttribute('tabindex', '-1'); + expect(enabled.table.tableAndTimelineContainer).toHaveAttribute('tabindex', '-1'); + + const disabled = await mount({ configuration: { keyboardNavigationEnabled: false } }); + expect(disabled.table.scrollContainer).not.toHaveAttribute('tabindex'); + expect(disabled.table.tableAndTimelineContainer).not.toHaveAttribute('tabindex'); + }); + + it('ArrowDown/ArrowUp walk visible rows and reach the header and totals rows', async () => { + const table = await mount(); + table.row('1').focus(); + + expect(pressActive('ArrowUp')).toBe(false); + expect(table.headerRow()).toHaveFocus(); + expect(table.headerRow()).toHaveAttribute('tabindex', '0'); + + pressActive('ArrowDown'); + pressActive('ArrowDown'); + expect(table.row('2')).toHaveFocus(); + expect(table.row('1')).toHaveAttribute('tabindex', '-1'); + + pressActive('ArrowDown'); + pressActive('ArrowDown'); + expect(table.totalsRow()).toHaveFocus(); + }); + + it('ArrowRight enters the first cell, ArrowLeft on it returns to the row', async () => { + const table = await mount(); + table.row('1').focus(); + + pressActive('ArrowRight'); + expect(cell(table.row('1'), 0)).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: 0, desiredColumn: 0 }); + + pressActive('ArrowLeft'); + expect(table.row('1')).toHaveFocus(); + }); + + it('ArrowUp from a cell keeps the column across a one-cell group header', async () => { + const table = await mount(grouped); + cell(table.row('3'), 1).focus(); + + pressActive('ArrowUp'); + expect(cell(table.groupHeader(1), 0)).toHaveFocus(); + + pressActive('ArrowUp'); + expect(cell(table.row('2'), 1)).toHaveFocus(); + }); + + it('ArrowRight on a collapsed hierarchy parent expands it without moving focus', async () => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + }); + const hierarchies = table.injector.get(WorkPackageViewHierarchiesService); + hierarchies.collapse('1'); + await waitFor(() => expect(table.row('2')).not.toBeVisible()); + table.row('1').focus(); + + expect(pressActive('ArrowRight')).toBe(false); + + expect(hierarchies.collapsed('1')).toBe(false); + await waitFor(() => expect(table.row('2')).toBeVisible()); + expect(table.row('1')).toHaveFocus(); + }); + + it('ArrowLeft on an expanded group header collapses it', async () => { + const table = await mount(grouped); + const collapsedGroups = table.injector.get(WorkPackageViewCollapsedGroupsService); + const identifier = table.groupHeader(0).dataset.groupIdentifier!; + table.groupHeader(0).focus(); + + expect(pressActive('ArrowLeft')).toBe(false); + + expect(collapsedGroups.config.state[identifier]).toBe(true); + await waitFor(() => expect(table.row('1')).not.toBeVisible()); + expect(table.groupHeader(0)).toHaveFocus(); + }); + + it('Enter on a work package row replaces the selection with that row and anchors it', async () => { + const table = await mount(); + const clicked:string[] = []; + table.outputs.itemClicked.subscribe(({ workPackageId }) => clicked.push(workPackageId)); + table.click('1'); + table.click('3', { ctrlKey: true }); + table.row('2').focus(); + + expect(pressActive('Enter')).toBe(false); + + expect(selectedIds(table)).toEqual(['2']); + expect(table.focus.focusedWorkPackage).toBe('2'); + expect(clicked).toEqual(['1', '2']); + table.click('3', { shiftKey: true }); + expect(selectedIds(table)).toEqual(['2', '3']); + }); + + it('Enter on a group header toggles its collapse', async () => { + const table = await mount(grouped); + const collapsedGroups = table.injector.get(WorkPackageViewCollapsedGroupsService); + const identifier = table.groupHeader(0).dataset.groupIdentifier!; + table.groupHeader(0).focus(); + + expect(pressActive('Enter')).toBe(false); + + expect(collapsedGroups.config.state[identifier]).toBe(true); + expect(selectedIds(table)).toEqual([]); + }); + + it('Enter on a header cell clicks its sort link; on the configuration cell clicks the button', async () => { + const table = await mount(); + const [idHeader, , configurationHeader] = table.headerCells(); + const linkClicked = vi.fn((event:Event) => event.preventDefault()); + const buttonClicked = vi.fn(); + idHeader.querySelector('a')!.addEventListener('click', linkClicked); + configurationHeader.querySelector('button')!.addEventListener('click', buttonClicked); + + idHeader.focus(); + expect(pressActive('Enter')).toBe(false); + configurationHeader.focus(); + expect(pressActive('Enter')).toBe(false); + + expect(linkClicked).toHaveBeenCalledOnce(); + expect(buttonClicked).toHaveBeenCalledOnce(); + }); + + it('Enter on a non-sortable column header clicks its link without an href', async () => { + const table = await mount({ columns: ['id', { id: 'subject', sortable: false }] }); + const [, subjectHeader] = table.headerCells(); + const link = subjectHeader.querySelector('a')!; + expect(link).not.toHaveAttribute('href'); + const clicked = vi.fn(); + link.addEventListener('click', clicked); + + subjectHeader.focus(); + expect(pressActive('Enter')).toBe(false); + + expect(clicked).toHaveBeenCalledOnce(); + }); + + it('Enter on an editable cell opens its inline editor', async () => { + const table = await mount({ editing: {} }); + const subjectCell = table.row('1').querySelector('td.subject')!; + subjectCell.focus(); + + expect(pressActive('Enter')).toBe(false); + + await waitFor(() => expect(within(table.row('1')).getByRole('textbox')).toHaveFocus()); + expect(selectedIds(table)).toEqual([]); + }); + + it('Enter on a read-only cell does nothing', async () => { + const table = await mount({ editing: {} }); + const idCell = table.row('1').querySelector('td.id')!; + idCell.focus(); + + expect(pressActive('Enter')).toBe(true); + + expect(within(table.row('1')).queryByRole('textbox')).toBeNull(); + expect(table.table.editing.forms['1']).toBeUndefined(); + expect(idCell).toHaveFocus(); + expect(selectedIds(table)).toEqual([]); + }); + + it.each([ + ['Alt', { altKey: true }], + ['composition', { isComposing: true }], + ['Ctrl', { ctrlKey: true }], + ['Shift', { shiftKey: true }], + ])('keys with %s are not handled', async (_name, init) => { + const table = await mount(); + table.row('2').focus(); + + expect(pressActive('ArrowDown', init)).toBe(true); + + expect(table.row('2')).toHaveFocus(); + expect(navigation(table).position?.key).toBe(wpOccurrenceKey('2')); + }); + + it('Shift+Enter on a work package row changes no selection', async () => { + const table = await mount(); + table.click('1'); + table.row('2').focus(); + + expect(pressActive('Enter', { shiftKey: true })).toBe(true); + + expect(selectedIds(table)).toEqual(['1']); + expect(table.focus.focusedWorkPackage).toBe('1'); + }); + + it('keys from inside an inner control are not handled', async () => { + const table = await mount({}, (harness) => { + harness.injector.get(DisplayFieldService).addFieldType(AnchorDisplayField, 'text', ['String']); + }); + const subjectCell = table.row('1').querySelector('td.subject')!; + subjectCell.focus(); + const link = within(subjectCell).getByRole('link'); + + expect(press(link, 'ArrowDown')).toBe(true); + expect(press(link, 'Enter')).toBe(true); + + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: 1, desiredColumn: 1 }); + expect(subjectCell).toHaveFocus(); + }); + + it('focus landing on a header sort link or a body inner control moves to its enclosing cell', async () => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + columns: ['id', 'subject'], + }); + const subjectHeader = table.headerCells()[1]; + + focusFromOutside(within(subjectHeader).getByRole('link')); + + expect(subjectHeader).toHaveFocus(); + expect(subjectHeader).toHaveAttribute('tabindex', '0'); + expect(navigation(table).position).toEqual({ key: headerRowKey, cell: 1, desiredColumn: 1 }); + + const toggle = table.row('1').querySelector('.wp-table--hierarchy-indicator')!; + const toggleCell = toggle.closest('td')!; + focusFromOutside(toggle); + + expect(toggleCell).toHaveFocus(); + expect(navigation(table).position).toEqual({ + key: wpOccurrenceKey('1'), + cell: navigableCells(table.row('1')).indexOf(toggleCell), + desiredColumn: navigableCells(table.row('1')).indexOf(toggleCell), + }); + }); + + it('focus moving onto an inner control from inside the table stays there', async () => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + }); + const toggle = table.row('1').querySelector('.wp-table--hierarchy-indicator')!; + cell(table.row('2'), 0).focus(); + const position = navigation(table).position; + + toggle.focus(); + + expect(toggle).toHaveFocus(); + expect(navigation(table).position).toEqual(position); + }); + + it('focus from a pointer press on an inner control stays there until the press ends', async () => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + }); + const toggle = table.row('1').querySelector('.wp-table--hierarchy-indicator')!; + const toggleCell = toggle.closest('td')!; + toggle.addEventListener('click', (event) => event.preventDefault()); + + expect(fireEvent.mouseDown(toggle, { button: 0 })).toBe(true); + toggle.focus(); + expect(toggle).toHaveFocus(); + + fireEvent.mouseUp(toggle, { button: 0 }); + fireEvent.click(toggle); + focusFromOutside(toggle); + + expect(toggleCell).toHaveFocus(); + expect(navigation(table).position).toEqual({ + key: wpOccurrenceKey('1'), + cell: navigableCells(table.row('1')).indexOf(toggleCell), + desiredColumn: navigableCells(table.row('1')).indexOf(toggleCell), + }); + }); + + it.each(['pointerUp', 'pointerCancel', 'dragEnd'] as const)('a pointer press ended by %s no longer holds back the redirect', async (end) => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + }); + const toggle = table.row('1').querySelector('.wp-table--hierarchy-indicator')!; + + fireEvent.mouseDown(toggle, { button: 0 }); + fireEvent[end](toggle); + toggle.focus(); + + expect(toggle.closest('td')).toHaveFocus(); + }); + + it('focus inside an open inline editor stays where the editor puts it', async () => { + const table = await mount({ editing: {} }); + table.row('1').querySelector('td.subject')!.focus(); + pressActive('Enter'); + const input = await waitFor(() => within(table.row('1')).getByRole('textbox')); + const editorControl = document.createElement('span'); + editorControl.tabIndex = -1; + input.after(editorControl); + + input.focus(); + expect(input).toHaveFocus(); + + editorControl.focus(); + expect(editorControl).toHaveFocus(); + }); + + it('focus movement never changes selection, anchor or the focus service', async () => { + const table = await mount(); + table.click('2'); + table.row('1').focus(); + + ['ArrowDown', 'ArrowDown', 'ArrowRight', 'ArrowUp', 'Home', 'End', 'ArrowLeft'].forEach((key) => pressActive(key)); + pressActive('Home', { ctrlKey: true }); + pressActive('End', { metaKey: true }); + + expect(navigableCells(table.totalsRow()).at(-1)).toHaveFocus(); + expect(selectedIds(table)).toEqual(['2']); + expect(table.focus.focusedWorkPackage).toBe('2'); + table.click('3', { shiftKey: true }); + expect(selectedIds(table)).toEqual(['2', '3']); + }); + + it('flag off: no keydown handling', async () => { + const table = await mount({ configuration: { keyboardNavigationEnabled: false } }); + table.row('1').focus(); + + expect(press(table.row('1'), 'ArrowDown')).toBe(true); + + expect(table.table.keyboardNavigation).toBeNull(); + expect(table.row('1')).toHaveFocus(); + }); + + it('returnFocus ignores a vanished origin and focus that left the table', async () => { + const table = await mount(); + const origin = { key: wpOccurrenceKey('2'), cell: 1, desiredColumn: 1 }; + const outside = document.createElement('button'); + document.body.append(outside); + try { + cell(table.row('1'), 0).focus(); + outside.focus(); + outside.blur(); + + navigation(table).returnFocus(origin); + + expect(document.body).toHaveFocus(); + outside.focus(); + navigation(table).returnFocus(origin); + expect(outside).toHaveFocus(); + outside.remove(); + cell(table.row('1'), 0).focus(); + cell(table.row('1'), 0).blur(); + navigation(table).returnFocus({ key: wpOccurrenceKey('99'), cell: 1, desiredColumn: 1 }); + expect(cell(table.row('1'), 0)).toHaveFocus(); + navigation(table).returnFocus(origin); + expect(cell(table.row('1'), 0)).toHaveFocus(); + cell(table.row('1'), 0).blur(); + navigation(table).returnFocus(origin); + expect(cell(table.row('2'), 1)).toHaveFocus(); + } finally { + outside.remove(); + } + }); + + it('returnFocus leaves focus alone after it left the grid for the inline-create button', async () => { + const table = await mount(); + const button = within(table.container).getByRole('button', { name: 'Create new work package' }); + button.focus(); + button.remove(); + + navigation(table).returnFocus({ key: wpOccurrenceKey('2'), cell: 1, desiredColumn: 1 }); + + expect(document.body).toHaveFocus(); + }); + + it('returnFocus leaves focus in another open editor of the table', async () => { + const table = await mount({ editing: {} }); + table.row('2').querySelector('td.subject')!.focus(); + pressActive('Enter'); + const input = await waitFor(() => within(table.row('2')).getByRole('textbox')); + await waitFor(() => expect(input).toHaveFocus()); + + navigation(table).returnFocus({ key: wpOccurrenceKey('1'), cell: 1, desiredColumn: 1 }); + + expect(input).toHaveFocus(); + }); + + it('consumes Home on the first cell without moving', async () => { + const table = await mount(); + cell(table.row('1'), 0).focus(); + + expect(pressActive('Home')).toBe(false); + + expect(cell(table.row('1'), 0)).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: 0, desiredColumn: 0 }); + }); + + it('two tables on one page do not share positions', async () => { + const states = new States(); + const first = await mount({ states }); + const second = await mount({ states }); + first.row('1').focus(); + + pressActive('ArrowDown'); + + expect(first.row('2')).toHaveFocus(); + expect(navigation(first).position?.key).toBe(wpOccurrenceKey('2')); + expect(navigation(second).position).toBeNull(); + expect(second.row('1')).toHaveAttribute('tabindex', '0'); + expect(second.row('2')).toHaveAttribute('tabindex', '-1'); + }); + + describe('pointer bookkeeping', () => { + const cellIndex = (row:HTMLTableRowElement, selector:string) => navigableCells(row).indexOf(row.querySelector(selector)!); + + it('mousedown on a cell records that row and cell without moving DOM focus', async () => { + const table = await mount(); + const subject = table.row('2').querySelector('td.subject')!; + + fireEvent.mouseDown(subject, { button: 0 }); + + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('2'), cell: 1, desiredColumn: 1 }); + expect(document.body).toHaveFocus(); + expect(cell(table.row('2'), 1)).toHaveAttribute('tabindex', '0'); + expect(table.row('1')).toHaveAttribute('tabindex', '-1'); + }); + + it('mousedown on the row outside navigable cells records row focus', async () => { + const table = await mount(); + + fireEvent.mouseDown(table.row('3'), { button: 0 }); + + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('3'), cell: null, desiredColumn: null }); + expect(document.body).toHaveFocus(); + expect(table.row('3')).toHaveAttribute('tabindex', '0'); + }); + + it('mousedown on the ID link, hierarchy toggle, relation indicator and group header records the position', async () => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + columns: ['id', 'subject', { id: 'relationsOfTypeFollows', relationType: 'follows' }], + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + editing: { fields: ['id', 'subject'] }, + }, (harness) => { + harness.injector.get(DisplayFieldService).addFieldType(AnchorDisplayField, 'text', ['String']); + }); + const hierarchies = table.injector.get(WorkPackageViewHierarchiesService); + const idCell = table.row('1').querySelector('td.id')!; + const link = within(idCell).getByRole('link'); + const toggle = table.row('1').querySelector('.wp-table--hierarchy-indicator')!; + const relation = table.row('1').querySelector('td.relationsOfTypeFollows *')!; + + expect(fireEvent.mouseDown(link, { button: 0 })).toBe(true); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: cellIndex(table.row('1'), 'td.id'), desiredColumn: cellIndex(table.row('1'), 'td.id') }); + + navigation(table).recordPosition({ key: wpOccurrenceKey('2'), cell: null, desiredColumn: null }); + const collapsedBefore = hierarchies.collapsed('1'); + fireEvent.mouseDown(toggle, { button: 0 }); + expect(navigation(table).position?.key).toBe(wpOccurrenceKey('1')); + fireEvent.click(toggle); + expect(hierarchies.collapsed('1')).toBe(!collapsedBefore); + + navigation(table).recordPosition({ key: wpOccurrenceKey('2'), cell: null, desiredColumn: null }); + fireEvent.mouseDown(relation, { button: 0 }); + expect(navigation(table).position).toEqual({ + key: wpOccurrenceKey('1'), + cell: cellIndex(table.row('1'), 'td.relationsOfTypeFollows'), + desiredColumn: cellIndex(table.row('1'), 'td.relationsOfTypeFollows'), + }); + }); + + it('closing the context menu opened by right-click on a display field returns focus to its cell', async () => { + const table = await mount({ configuration: { contextMenuEnabled: true } }); + vi.spyOn(table.injector.get(WorkPackageContextMenuHelperService), 'getPermittedActions').mockReturnValue([]); + let menu:OpContextMenuHandler|undefined; + vi.spyOn(table.injector.get(OPContextMenuService), 'show').mockImplementation((handler) => { menu = handler; }); + const subjectCell = table.row('1').querySelector('td.subject')!; + const field = subjectCell.querySelector('.inline-edit--display-field')!; + + fireEvent.mouseDown(field, { button: 2 }); + fireEvent.contextMenu(field); + menu!.onClose(true); + + expect(subjectCell).toHaveFocus(); + }); + + it('mousedown on a group header records the group position', async () => { + const table = await mount(grouped); + const header = table.groupHeader(1); + + fireEvent.mouseDown(header.querySelector('td, th') ?? header, { button: 0 }); + + expect(navigation(table).position).toMatchObject({ + key: table.table.ledger.byElement(header)!.key, + groupIdentifier: header.dataset.groupIdentifier, + }); + }); + + it('mousedown on the timeline side or the inline-create row changes nothing', async () => { + const table = await mount({ timelineVisible: true }); + const before = { key: wpOccurrenceKey('1'), cell: 0, desiredColumn: 0 }; + navigation(table).recordPosition(before); + const inlineCreate = table.addInlineCreateRow(); + + fireEvent.mouseDown(table.timelineRow('2'), { button: 0 }); + fireEvent.mouseDown(inlineCreate.querySelector('td') ?? inlineCreate, { button: 0 }); + + expect(navigation(table).position).toEqual(before); + }); + }); + + describe('recovery', () => { + it('after a full re-render the same occurrence keeps focus', async () => { + const table = await mount(); + cell(table.row('2'), 1).focus(); + + await table.render([{ id: '3' }, { id: '2' }, { id: '1' }]); + + expect(cell(table.row('2'), 1)).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('2'), cell: 1, desiredColumn: 1 }); + }); + + it('after a re-render that removed the focused row, the nearest visible row gets focus', async () => { + const table = await mount(); + table.row('2').focus(); + + await table.render([{ id: '1' }, { id: '3' }]); + + expect(table.row('3')).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('3'), cell: null, desiredColumn: null }); + }); + + it('a re-render while focus is in another table does not steal focus', async () => { + const states = new States(); + const first = await mount({ states }); + const second = await mount({ states }); + first.row('2').focus(); + second.row('1').focus(); + + await first.render(); + + expect(second.row('1')).toHaveFocus(); + expect(navigation(first).position?.key).toBe(wpOccurrenceKey('2')); + expect(first.row('2')).toHaveAttribute('tabindex', '0'); + }); + + it('a row refresh re-focuses the replaced cell at the same column', async () => { + const table = await mount(); + const replaced = cell(table.row('2'), 1); + replaced.focus(); + + table.table.refreshRows(resource(table, '2')); + + expect(replaced).not.toBeInTheDocument(); + expect(cell(table.row('2'), 1)).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('2'), cell: 1, desiredColumn: 1 }); + }); + + it('ifShouldFocus wins over the remembered position and becomes it', async () => { + const table = await mount(); + const focusHelper = vi.spyOn(table.injector.get(FocusHelperService), 'focus'); + cell(table.row('1'), 1).focus(); + + table.focus.updateFocus('3', true); + await table.render(); + + expect(table.row('3')).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('3'), cell: null, desiredColumn: null }); + expect(focusHelper).not.toHaveBeenCalled(); + }); + + it('a group header position survives an earlier group disappearing', async () => { + const table = await mount(grouped); + const identifier = table.groupHeader(1).dataset.groupIdentifier; + table.groupHeader(1).focus(); + + table.querySpace.groups.putValue([buildGroup({ value: 'In progress', href: inProgressStatus.href, count: 1 }, 'status', 0)]); + await table.render([{ id: '3', attributes: { status: inProgressStatus } }]); + + expect(table.groupHeader(0)).toHaveFocus(); + expect(table.groupHeader(0).dataset.groupIdentifier).toBe(identifier); + expect(navigation(table).position).toMatchObject({ key: table.table.ledger.byElement(table.groupHeader(0))!.key, groupIdentifier: identifier }); + }); + + it('a group header position is dropped when groupBy changes', async () => { + const table = await mount(grouped); + table.groupHeader(1).focus(); + const bug = { href: '/api/v3/types/1' }; + const task = { href: '/api/v3/types/2' }; + + table.querySpace.query.putValue(buildQuery(['id', 'subject'], 'type', false, false)); + table.querySpace.groups.putValue([ + buildGroup({ value: 'Bug', href: bug.href, count: 1 }, 'type', 0), + buildGroup({ value: 'Task', href: task.href, count: 2 }, 'type', 1), + ]); + await table.render([ + { id: '1', attributes: { type: bug } }, + { id: '2', attributes: { type: task } }, + { id: '3', attributes: { type: task } }, + ]); + + expect(table.row('2')).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('2'), cell: null, desiredColumn: null }); + }); + + it('collapsing the focused row\'s parent moves focus to the parent', async () => { + const table = await mount({ + workPackages: [ + { id: '1', children: [{ id: '2' }, { id: '3' }] }, + { id: '2', ancestors: [{ id: '1' }] }, + { id: '3', ancestors: [{ id: '1' }] }, + { id: '4' }, + ], + showHierarchies: true, + }); + table.row('3').focus(); + + table.injector.get(WorkPackageViewHierarchiesService).collapse('1'); + + await waitFor(() => expect(table.row('3')).not.toBeVisible()); + expect(table.row('1')).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: null, desiredColumn: null }); + }); + + it('filtering to zero rows moves focus to the header row', async () => { + const table = await mount(); + cell(table.row('2'), 1).focus(); + + await table.render([]); + + expect(table.headerRow()).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: 'header', cell: null, desiredColumn: null }); + }); + + it('does not restore after a render when focus left the grid and the outside control went away', async () => { + const table = await mount(); + const outside = document.createElement('button'); + document.body.append(outside); + try { + table.row('1').focus(); + navigation(table).captureOwnership(); + outside.focus(); + outside.remove(); + + table.table.refreshRows(resource(table, '1')); + + expect(document.body).toHaveFocus(); + expect(table.row('1')).toHaveAttribute('tabindex', '0'); + } finally { + outside.remove(); + } + }); + + it('leaves focus on a still-connected inner control when another row refreshes', async () => { + const table = await mount({}, (harness) => { + harness.injector.get(DisplayFieldService).addFieldType(AnchorDisplayField, 'text', ['String']); + }); + const subjectCell = table.row('1').querySelector('td.subject')!; + const link = within(subjectCell).getByRole('link'); + fireEvent.mouseDown(link, { button: 0 }); + link.focus(); + + table.table.refreshRows(resource(table, '2')); + + expect(link).toHaveFocus(); + expect(subjectCell).toHaveAttribute('tabindex', '0'); + fireEvent.mouseUp(link, { button: 0 }); + }); + + it('restores to the hiding parent when focus sits on a row a collapse hid without moving focus', async () => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }, { id: '3' }], + showHierarchies: true, + }); + cell(table.row('2'), 1).focus(); + + table.injector.get(WorkPackageViewHierarchiesService).collapse('1'); + + await waitFor(() => expect(table.row('2')).not.toBeVisible()); + expect(table.row('1')).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: null, desiredColumn: null }); + }); + + it('moves focus off a row the ledger hides while it stays focused and visible', async () => { + const table = await mount(); + table.row('2').focus(); + + table.table.setHidden(new Map([[wpOccurrenceKey('2'), true]])); + + expect(table.row('2')).toBeVisible(); + expect(table.row('1')).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: wpOccurrenceKey('1'), cell: null, desiredColumn: null }); + }); + + it('publishes a render when the collapse hides no stamped rows', async () => { + const table = await mount(grouped); + table.rows().forEach((row) => { delete row.dataset.occurrenceKey; }); + const header = table.groupHeader(0); + header.focus(); + + table.table.setGroupsCollapseState({ [header.dataset.groupIdentifier!]: true }); + + expect(table.groupHeader(0)).not.toBe(header); + expect(table.groupHeader(0)).toHaveFocus(); + }); + + it('reaches the totals row after the sums row renders late', async () => { + const table = await mount(); + const totals = table.totalsRow(); + const cells = totals.innerHTML; + totals.innerHTML = ''; + await table.render(); + cell(table.row('3'), 0).focus(); + + totals.innerHTML = cells; + table.table.notifyRowsRefreshed(totalsRowKey); + + expect(navigableCells(totals).every((totalsCell) => totalsCell.getAttribute('tabindex') === '-1')).toBe(true); + expect(pressActive('ArrowDown')).toBe(false); + expect(navigableCells(totals)[0]).toHaveFocus(); + }); + + it('keeps focus on the totals row across a sums refresh', async () => { + const table = await mount(); + const totals = table.totalsRow(); + navigableCells(totals)[1].focus(); + + navigation(table).captureOwnership(); + totals.innerHTML = totals.innerHTML.replaceAll(/ tabindex="[^"]*"/g, ''); + table.table.notifyRowsRefreshed(totalsRowKey); + + expect(navigableCells(totals)[1]).toHaveFocus(); + expect(navigation(table).position).toEqual({ key: totalsRowKey, cell: 1, desiredColumn: 1 }); + }); + + it('keeps ownership captured during a pending render when a second capture sees body', async () => { + const table = await mount(); + const frames:FrameRequestCallback[] = []; + const frameSpy = vi.spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => frames.push(callback)); + try { + table.row('2').focus(); + table.table.redrawTableAndTimeline(); + await Promise.resolve(); + table.row('2').blur(); + navigation(table).captureOwnership(); + await Promise.resolve(); + + frames.splice(0).forEach((frame) => frame(0)); + + expect(table.row('2')).toHaveFocus(); + } finally { + frameSpy.mockRestore(); + } + }); + + it('a row refresh between scheduling a full render and its commit leaves focus to the commit', async () => { + const table = await mount(); + const frames:FrameRequestCallback[] = []; + const frameSpy = vi.spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => frames.push(callback)); + try { + table.row('2').focus(); + table.table.redrawTableAndTimeline(); + await Promise.resolve(); + table.table.refreshRows(resource(table, '2')); + const refreshed = table.row('2'); + + frames.splice(0).forEach((frame) => frame(0)); + + expect(table.row('2')).not.toBe(refreshed); + expect(table.row('2')).toHaveFocus(); + } finally { + frameSpy.mockRestore(); + } + }); + + it('a table destroyed before the rAF commit restores nothing and throws nothing', async () => { + const table = await mount(); + const frames:FrameRequestCallback[] = []; + const frameSpy = vi.spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => frames.push(callback)); + try { + table.row('2').focus(); + table.table.redrawTableAndTimeline(); + table.table.destroy(); + table.row('2').blur(); + + expect(() => frames.splice(0).forEach((frame) => frame(0))).not.toThrow(); + table.querySpace.tableRendered.putValue(table.querySpace.tableRendered.value); + + expect(document.body).toHaveFocus(); + } finally { + frameSpy.mockRestore(); + } + }); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.ts new file mode 100644 index 000000000000..54e69bdd28b3 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.ts @@ -0,0 +1,527 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Injector } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs/operators'; +import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; +import { WorkPackageViewCollapsedGroupsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service'; +import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; +import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service'; +import { displayClassName, editableClassName } from 'core-app/shared/components/fields/display/display-field-renderer'; +import { activeFieldContainerClassName } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; +import { onDestroySafely } from 'core-app/shared/helpers/angular/owned-ui-cleanup'; +import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; +import type { WorkPackageTable } from '../../../wp-fast-table'; +import { wpOccurrenceKey } from '../../../rendered-occurrence-ledger'; +import type { TableEventComponent } from '../../table-handler-registry'; +import { activateTableField } from '../../cell/edit-cell-handler'; +import { activateRow } from '../../row/click-handler'; +import { + FocusPosition, initialPosition, NavigableRow, NavKey, nearestPosition, computeNextPosition, reconcilePosition, +} from './focus-position'; +import { + buildNavigableRows, headerRowKey, headerRowOf, rowKindOf, totalsRowKey, totalsRowOf, +} from './navigable-rows'; +import { insideGrid, navigableCellClassName, navigableCells, paintRovingTabindex } from './roving-tabindex'; + +export interface KeyboardNavigation { + readonly position:FocusPosition|null; + /** Makes the position the table's Tab stop, focuses it and scrolls it into view. */ + focusPosition(position:FocusPosition):void; + /** Remembers the position without painting or focusing it. */ + recordPosition(position:FocusPosition):void; + /** Focuses the origin again, or the row hiding it, unless the user has since moved elsewhere, an editor is open or focus rests on a live control. */ + returnFocus(origin:FocusPosition):void; + /** The position of the navigable cell or row enclosing `cell`, or null when it is not a rendered occurrence. */ + positionOfCell(cell:HTMLElement):FocusPosition|null; + /** The row or cell element at the position, or null when it is not rendered. */ + elementAt(position:FocusPosition):HTMLElement|null; + /** Handles Enter on a body cell; true when it acted. */ + onCellEnter:(position:FocusPosition) => boolean; + /** Records whether focus is inside the table before a mutation replaces or hides its rows. */ + captureOwnership():void; +} + +interface RenderedRows { + rows:NavigableRow[]; + groupBy:string|null; +} + +const plainKeys:Record = { + ArrowUp: 'ArrowUp', + ArrowDown: 'ArrowDown', + ArrowLeft: 'ArrowLeft', + ArrowRight: 'ArrowRight', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', +}; + +const commandKeys:Record = { + Home: 'CtrlHome', + End: 'CtrlEnd', +}; + +const pointerEndEvents = ['mouseup', 'click', 'pointerup', 'pointercancel', 'dragend'] as const; + +/** Moves focus across the rows and cells of a table with the keyboard, behind `keyboardNavigationEnabled`. */ +export class KeyboardNavigationTransformer implements KeyboardNavigation { + @LazyInject() public querySpace:IsolatedQuerySpace; + + @LazyInject() public wpTableHierarchies:WorkPackageViewHierarchiesService; + + @LazyInject() public collapsedGroups:WorkPackageViewCollapsedGroupsService; + + @LazyInject() public wpTableFocus:WorkPackageViewFocusService; + + private current:FocusPosition|null = null; + + private userPosition:FocusPosition|null = null; + + private owned = false; + + private focusLeftTable = false; + + private capturing = false; + + private pointerActive = false; + + private rendered:RenderedRows = { rows: [], groupBy: null }; + + public onCellEnter = (position:FocusPosition):boolean => { + const field = this.elementAt(position)?.querySelector(`.${displayClassName}.${editableClassName}`); + return !!field && activateTableField(this.table, field); + }; + + constructor( + public readonly injector:Injector, + private readonly table:WorkPackageTable, + private readonly view:TableEventComponent|null = null, + ) { + if (!table.configuration.keyboardNavigationEnabled) { + return; + } + + const { destroyRef, scrollContainer } = table; + table.keyboardNavigation = this; + const removedFromTabOrder = [scrollContainer, table.tableAndTimelineContainer] + .filter((container, index, containers) => containers.indexOf(container) === index && !container.hasAttribute('tabindex')); + removedFromTabOrder.forEach((container) => container.setAttribute('tabindex', '-1')); + onDestroySafely(destroyRef, () => { + removedFromTabOrder.forEach((container) => container.removeAttribute('tabindex')); + if (table.keyboardNavigation === this) { + table.keyboardNavigation = null; + } + }); + + const onKeydown = (event:KeyboardEvent) => this.handleKeydown(event); + const onFocusin = (event:FocusEvent) => { + this.focusLeftTable = !insideGrid(scrollContainer, event.target as Node); + const from = event.relatedTarget; + this.adoptFocus(event.target as HTMLElement, !insideGrid(scrollContainer, from as Node|null)); + }; + const onFocusout = (event:FocusEvent) => { + const next = event.relatedTarget ?? event.target; + this.focusLeftTable = !insideGrid(scrollContainer, next as Node); + }; + scrollContainer.addEventListener('keydown', onKeydown); + const onMousedown = (event:MouseEvent) => { + this.pointerActive = true; + this.recordPointerTarget(event.target); + }; + const onPointerEnd = () => { this.pointerActive = false; }; + scrollContainer.addEventListener('focusin', onFocusin); + scrollContainer.addEventListener('focusout', onFocusout); + scrollContainer.addEventListener('mousedown', onMousedown); + pointerEndEvents.forEach((type) => document.addEventListener(type, onPointerEnd)); + onDestroySafely(destroyRef, () => { + scrollContainer.removeEventListener('keydown', onKeydown); + scrollContainer.removeEventListener('focusin', onFocusin); + scrollContainer.removeEventListener('focusout', onFocusout); + scrollContainer.removeEventListener('mousedown', onMousedown); + pointerEndEvents.forEach((type) => document.removeEventListener(type, onPointerEnd)); + }); + + this.querySpace.tableRendered.values$() + .pipe( + filter(() => table.ledger.generation > 0), + takeUntilDestroyed(destroyRef), + ) + .subscribe(() => this.recover(this.requestedPosition())); + + table.rowsRefreshed$ + .pipe(takeUntilDestroyed(destroyRef)) + .subscribe(() => this.recover(null)); + } + + public get position():FocusPosition|null { + return this.current; + } + + public focusPosition(position:FocusPosition):void { + const element = this.moveFocusTo(position); + if (element) { + this.scrollIntoView(element); + } + } + + public recordPosition(position:FocusPosition):void { + this.current = position; + this.userPosition = position; + } + + public captureOwnership():void { + const inside = insideGrid(this.table.scrollContainer, document.activeElement); + if (this.capturing || this.table.hasPendingRender) { + this.owned ||= inside; + } else { + this.owned = inside; + } + + if (!this.capturing) { + this.capturing = true; + queueMicrotask(() => { this.capturing = false; }); + } + } + + public returnFocus(origin:FocusPosition):void { + const user = this.userPosition; + if (user && (user.key !== origin.key || user.cell !== origin.cell)) { + return; + } + + const rows = buildNavigableRows(this.table); + const originIndex = rows.findIndex((row) => row.key === origin.key); + const target = originIndex === -1 ? this.current : reconcilePosition(rows, origin, originIndex); + if (target && this.mayRestoreFocus()) { + this.focusPosition(target); + } + } + + public positionOfCell(cell:Element):FocusPosition|null { + return this.positionOf(cell.closest(`.${navigableCellClassName}`) ?? cell.closest('tr')); + } + + public elementAt(position:FocusPosition):HTMLElement|null { + const row = this.rowElement(position.key); + if (!row || position.cell === null) { + return row; + } + + return navigableCells(row)[position.cell] ?? null; + } + + private rowElement(key:string):HTMLTableRowElement|null { + switch (key) { + case headerRowKey: + return headerRowOf(this.table); + case totalsRowKey: + return totalsRowOf(this.table); + default: + return this.table.ledger.byKey(key)?.element ?? null; + } + } + + private requestedPosition():FocusPosition|null { + let requested:FocusPosition|null = null; + this.wpTableFocus.ifShouldFocus((workPackageId) => { + requested = { key: wpOccurrenceKey(workPackageId), cell: null, desiredColumn: null }; + }); + return requested; + } + + private recover(requested:FocusPosition|null):void { + if (this.table.destroyed) { + return; + } + + const owned = this.owned; + if (!this.table.hasPendingRender) { + this.owned = false; + } + const previous = this.rendered; + const rows = buildNavigableRows(this.table); + this.rendered = { rows, groupBy: this.groupBy() }; + + if (requested && this.elementAt(requested)) { + this.userPosition = requested; + this.focusPosition(requested); + return; + } + + this.current = this.current && this.reconcile(rows, previous, this.current); + if (this.current && owned && this.mayRestoreFocus()) { + this.focusPosition(this.current); + return; + } + + const painted = this.current ?? initialPosition(rows); + paintRovingTabindex(this.table.scrollContainer, painted && this.elementAt(painted)); + } + + private reconcile(rows:NavigableRow[], previous:RenderedRows, position:FocusPosition):FocusPosition|null { + if (!rows.some((row) => row.kind !== 'header' && row.kind !== 'totals')) { + const header = rows.find((row) => row.kind === 'header'); + return header ? { key: header.key, cell: null, desiredColumn: null } : null; + } + + const previousIndex = previous.rows.findIndex((row) => row.key === position.key); + const regrouped = position.groupIdentifier !== undefined && previous.groupBy !== this.rendered.groupBy; + return regrouped ? nearestPosition(rows, previousIndex) : reconcilePosition(rows, position, previousIndex); + } + + private mayRestoreFocus():boolean { + const editing = Object.values(this.table.editing.forms).some((form) => form.hasActiveFields()); + return !editing && !this.focusLeftTable && this.focusAtRest(); + } + + private focusAtRest():boolean { + const active = document.activeElement; + return !active + || active === document.body + || !active.isConnected + || (insideGrid(this.table.scrollContainer, active) && this.hiddenOrUnlisted(active)); + } + + private hiddenOrUnlisted(element:Element):boolean { + const row = element.closest('tr'); + if (!row) { + return true; + } + + if (this.edgeKeyOf(row) !== undefined) { + return false; + } + + return this.table.ledger.byElement(row)?.hidden ?? true; + } + + private groupBy():string|null { + return this.querySpace.query.value?.groupBy?.id ?? null; + } + + private adoptFocus(target:HTMLElement, fromOutside:boolean):void { + if (this.current && this.elementAt(this.current) === target) { + return; + } + + const position = this.positionOf(target); + if (position) { + this.current = position; + paintRovingTabindex(this.table.scrollContainer, target); + return; + } + + const enclosing = this.pointerActive || !fromOutside ? null : this.enclosingPositionOf(target); + if (enclosing) { + this.moveFocusTo(enclosing); + } + } + + private moveFocusTo(position:FocusPosition):HTMLElement|null { + const element = this.elementAt(position); + if (!element) { + return null; + } + + this.current = position; + paintRovingTabindex(this.table.scrollContainer, element); + element.focus({ preventScroll: true }); + return element; + } + + private enclosingPositionOf(innerControl:HTMLElement):FocusPosition|null { + if (innerControl.getAttribute('tabindex') !== '-1' || innerControl.closest(`.${activeFieldContainerClassName}`)) { + return null; + } + + return this.positionOfCell(innerControl); + } + + private recordPointerTarget(target:EventTarget|null):void { + if (!(target instanceof Element)) { + return; + } + + const position = this.positionOfCell(target); + if (position) { + this.current = position; + this.userPosition = position; + paintRovingTabindex(this.table.scrollContainer, this.elementAt(position)); + } + } + + private positionOf(target:HTMLElement|null):FocusPosition|null { + if (!target) { + return null; + } + + const isCell = target.classList.contains(navigableCellClassName); + const row = isCell ? target.closest('tr') : target; + if (!(row instanceof HTMLTableRowElement)) { + return null; + } + + const occurrence = this.table.ledger.byElement(row); + const key = this.edgeKeyOf(row) ?? occurrence?.key; + if (key === undefined) { + return null; + } + + const cell = isCell ? navigableCells(row).indexOf(target as HTMLTableCellElement) : null; + const position:FocusPosition = { key, cell, desiredColumn: cell }; + const groupIdentifier = occurrence?.groupIdentifier; + return groupIdentifier === undefined ? position : { ...position, groupIdentifier }; + } + + private edgeKeyOf(row:HTMLTableRowElement):string|undefined { + if (row === headerRowOf(this.table)) { + return headerRowKey; + } + + return row === totalsRowOf(this.table) ? totalsRowKey : undefined; + } + + private handleKeydown(event:KeyboardEvent):void { + const position = this.current; + if (position && event.key === 'Tab' && event.target === this.elementAt(position)) { + paintRovingTabindex(this.table.scrollContainer, event.target as HTMLElement); + return; + } + + if (!position || this.ignored(event) || event.target !== this.elementAt(position)) { + return; + } + + const handled = event.key === 'Enter' + ? this.activate(position) + : this.navigate(position, event); + + if (handled) { + event.preventDefault(); + } + } + + private ignored(event:KeyboardEvent):boolean { + const command = event.ctrlKey || event.metaKey; + return event.isComposing + || event.altKey + || event.shiftKey + || event.getModifierState('AltGraph') + || (command && !(event.key in commandKeys)); + } + + private navigate(position:FocusPosition, event:KeyboardEvent):boolean { + const key = (event.ctrlKey || event.metaKey ? commandKeys : plainKeys)[event.key]; + if (!key) { + return false; + } + + const rows = buildNavigableRows(this.table); + const result = computeNextPosition(rows, position, key, this.pageSize(rows)); + switch (result.type) { + case 'move': + this.userPosition = result.position; + this.focusPosition(result.position); + return true; + case 'expand': + case 'collapse': + this.toggle(rows.find((row) => row.key === result.key)!); + this.focusPosition(position); + return true; + default: + return true; + } + } + + private toggle(row:NavigableRow):void { + if (row.kind === 'groupHeader') { + this.collapsedGroups.toggleGroupCollapseState(row.groupIdentifier ?? ''); + return; + } + + const workPackageId = this.table.ledger.byKey(row.key)?.workPackageId; + if (workPackageId) { + this.wpTableHierarchies.toggle(workPackageId); + } + } + + private activate(position:FocusPosition):boolean { + if (position.cell !== null) { + return position.key === headerRowKey + ? this.clickHeaderControl(this.elementAt(position)) + : this.onCellEnter(position); + } + + const occurrence = this.table.ledger.byKey(position.key); + if (!occurrence) { + return false; + } + + switch (rowKindOf(occurrence)) { + case 'groupHeader': + this.collapsedGroups.toggleGroupCollapseState(occurrence.groupIdentifier ?? ''); + this.focusPosition(position); + return true; + case 'workPackage': + return this.activateWorkPackageRow(occurrence.workPackageId!, occurrence.element); + default: + return false; + } + } + + private activateWorkPackageRow(workPackageId:string, element:HTMLTableRowElement|null):boolean { + if (!element || !this.view) { + return false; + } + + activateRow(this.view, workPackageId, element); + return true; + } + + private clickHeaderControl(cell:HTMLElement|null):boolean { + const control = cell?.querySelector('a') + ?? cell?.querySelector('button.wp-table--columns-selection'); + control?.click(); + return !!control; + } + + private pageSize(rows:NavigableRow[]):number { + const first = rows.find((row) => !row.hidden && row.kind === 'workPackage'); + const rowHeight = first ? this.rowElement(first.key)?.getBoundingClientRect().height ?? 0 : 0; + return rowHeight > 0 ? Math.max(1, Math.floor(this.table.scrollContainer.clientHeight / rowHeight)) : 1; + } + + private scrollIntoView(element:HTMLElement):void { + if (this.table.tbody.contains(element)) { + element.style.scrollMarginTop = `${headerRowOf(this.table)?.getBoundingClientRect().height ?? 0}px`; + } + element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows.ts new file mode 100644 index 000000000000..3b1eb59aa8bb --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows.ts @@ -0,0 +1,119 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; +import type { RenderedOccurrence } from '../../../rendered-occurrence-ledger'; +import type { WorkPackageTable } from '../../../wp-fast-table'; +import { NavigableRow, RowKind } from './focus-position'; +import { navigableCells } from './roving-tabindex'; + +export const headerRowKey = 'header'; +export const totalsRowKey = 'totals'; + +/** The `thead` row of the table, when rendered. */ +export function headerRowOf(table:WorkPackageTable):HTMLTableRowElement|null { + return table.tbody.closest('table')?.querySelector('thead tr') ?? null; +} + +/** The totals row below the results, when rendered. */ +export function totalsRowOf(table:WorkPackageTable):HTMLTableRowElement|null { + const rows = table.tbody.closest('table')?.querySelectorAll('tr.wp-table--sums-row') ?? []; + return Array.from(rows).find((row) => !table.tbody.contains(row)) ?? null; +} + +/** The row's navigation category, or null when the occurrence is excluded from keyboard navigation. */ +export function rowKindOf(occurrence:RenderedOccurrence):RowKind|null { + if (occurrence.key.startsWith('group:')) { + return occurrence.key.endsWith(':sums') ? 'sums' : 'groupHeader'; + } + + return occurrence.workPackageId === null ? null : 'workPackage'; +} + +function expansionStateOf( + table:WorkPackageTable, + hierarchies:WorkPackageViewHierarchiesService, + occurrence:RenderedOccurrence, + kind:RowKind, +):boolean|null { + if (kind === 'groupHeader') { + return !table.workPackageViewCollapsedGroupsService.config.state[occurrence.groupIdentifier ?? '']; + } + + if (kind === 'workPackage' && occurrence.element?.querySelector('.wp-table--hierarchy-indicator')) { + return !hierarchies.collapsed(occurrence.workPackageId!); + } + + return null; +} + +function occurrenceRow( + table:WorkPackageTable, + hierarchies:WorkPackageViewHierarchiesService, + occurrence:RenderedOccurrence, +):NavigableRow|null { + const kind = rowKindOf(occurrence); + if (kind === null || !occurrence.element) { + return null; + } + + const row:NavigableRow = { + key: occurrence.key, + kind, + hidden: occurrence.hidden, + cellCount: navigableCells(occurrence.element).length, + expanded: expansionStateOf(table, hierarchies, occurrence, kind), + }; + + return occurrence.groupIdentifier === undefined ? row : { ...row, groupIdentifier: occurrence.groupIdentifier }; +} + +function edgeRow(key:string, kind:RowKind, element:HTMLTableRowElement|null):NavigableRow[] { + if (!element) { + return []; + } + + const cellCount = navigableCells(element).length; + return [{ + key, kind, hidden: kind === 'totals' && cellCount === 0, cellCount, expanded: null, + }]; +} + +/** The rows keyboard navigation walks, in visual order: header, rendered occurrences, totals. */ +export function buildNavigableRows(table:WorkPackageTable):NavigableRow[] { + const { ledger } = table; + const hierarchies = table.injector.get(WorkPackageViewHierarchiesService); + const body = Array.from({ length: ledger.size }, (_, index) => ledger.byKey(ledger.keyAt(index)!)!) + .map((occurrence) => occurrenceRow(table, hierarchies, occurrence)) + .filter((row):row is NavigableRow => row !== null); + + return [ + ...edgeRow(headerRowKey, 'header', headerRowOf(table)), + ...body, + ...edgeRow(totalsRowKey, 'totals', totalsRowOf(table)), + ]; +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex.spec.ts new file mode 100644 index 000000000000..579effcc8d6b --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex.spec.ts @@ -0,0 +1,343 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { fireEvent, waitFor, within } from '@testing-library/dom'; +import { DisplayFieldRenderer } from 'core-app/shared/components/fields/display/display-field-renderer'; +import { DisplayFieldService } from 'core-app/shared/components/fields/display/display-field.service'; +import { TextDisplayField } from 'core-app/shared/components/fields/display/field-types/text-display-field.module'; +import { WorkPackageViewOrderService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-order.service'; +import { buildWorkPackage } from '../../../testing/work-package-fixture'; +import { buildTable, TableHarness } from '../../../testing/table-harness'; +import { QueryColumn, queryColumnTypes } from 'core-app/features/work-packages/components/wp-query/query-column'; +import { WorkPackageTableConfiguration, WorkPackageTableConfigurationObject } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { + internalBaselineColumn, + internalContextMenuColumn, + internalSortColumn, +} from '../../../builders/internal-sort-columns'; +import { + isNavigableColumn, + navigableCellClassName, + navigableCells, + paintRovingTabindex, +} from './roving-tabindex'; + +class AnchorDisplayField extends TextDisplayField { + public render(element:HTMLElement, displayText:string):void { + const link = document.createElement('a'); + link.href = '#'; + link.textContent = displayText; + element.appendChild(link); + } +} + +class LateLinkDisplayField extends TextDisplayField { + public render(element:HTMLElement, displayText:string):void { + const link = document.createElement('a'); + link.textContent = displayText; + element.appendChild(link); + setTimeout(() => { link.href = '#'; }); + } +} + +describe('roving tabindex', () => { + let harness:TableHarness|undefined; + + const mount = async ( + keyboardNavigationEnabled:boolean, + extra:Parameters[0]['configuration'] = {}, + options:Partial[0]> = {}, + ) => { + const status = { name: 'Open', href: '/api/v3/statuses/1' }; + harness = buildTable({ + workPackages: [{ id: '1', attributes: { status } }, { id: '2', attributes: { status } }], + productionMarkup: true, + editing: {}, + groups: [{ value: 'Open', href: '/api/v3/statuses/1', count: 2, sums: { estimatedTime: 'PT1H' } as never }], + configuration: { keyboardNavigationEnabled, ...extra }, + ...options, + }); + await harness.render(); + return harness; + }; + + const tabbables = (root:HTMLElement) => Array.from(root.querySelectorAll('[tabindex="0"]')); + + const subjectField = (table:TableHarness) => table.row('1').querySelector('td.subject .inline-edit--display-field')!; + + afterEach(async () => { + await harness?.destroy(); + harness = undefined; + }); + + describe('stray Tab stops', () => { + it('sweeps a Tab stop a custom element added after the display field rendered', async () => { + const table = await mount(true); + const late = document.createElement('span'); + late.setAttribute('tabindex', '0'); + table.row('1').querySelector('td.subject')!.appendChild(late); + + paintRovingTabindex(table.table.scrollContainer, table.row('1')); + + expect(late).toHaveAttribute('tabindex', '-1'); + }); + + it('leaves the inline-create button and an open editor input alone', async () => { + const table = await mount(true); + const editor = document.createElement('div'); + editor.className = 'inline-edit--active-field'; + const input = document.createElement('input'); + editor.appendChild(input); + table.row('1').querySelector('td.subject')!.appendChild(editor); + const button = within(table.container).getByRole('button', { name: 'Create new work package' }); + + paintRovingTabindex(table.table.scrollContainer, table.row('2')); + + expect(input).not.toHaveAttribute('tabindex', '-1'); + expect(button).not.toHaveAttribute('tabindex'); + expect(button.closest('tr')).not.toHaveAttribute('tabindex'); + }); + }); + + describe('with the flag off', () => { + it('keeps rows and editable fields tabbable', async () => { + const table = await mount(false); + + expect(table.row('1')).toHaveAttribute('tabindex', '0'); + expect(subjectField(table)).toHaveAttribute('tabindex', '0'); + expect(subjectField(table)).toHaveAttribute('role', 'button'); + expect(table.container.querySelector(`.${navigableCellClassName}`)).toBeNull(); + }); + + it.each([ + ['the BCF list', { dragAndDropEnabled: false }], + ['an embedded table', { isEmbedded: true }], + ])('keeps today\'s Tab stops and handles no keys in %s', async (_name, configuration) => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + productionMarkup: true, + editing: {}, + configuration, + }); + await harness.render(); + harness.row('1').focus(); + + expect(harness.table.configuration.keyboardNavigationEnabled).toBe(false); + expect(harness.row('1')).toHaveAttribute('tabindex', '0'); + expect(harness.row('2')).toHaveAttribute('tabindex', '0'); + expect(subjectField(harness)).toHaveAttribute('tabindex', '0'); + expect(subjectField(harness)).toHaveAttribute('role', 'button'); + expect(harness.table.keyboardNavigation).toBeNull(); + expect(fireEvent.keyDown(harness.row('1'), { key: 'ArrowDown' })).toBe(true); + expect(harness.row('1')).toHaveFocus(); + }); + + it('leaves the header sort link without a tabindex', async () => { + const table = await mount(false); + + expect(table.headerCells()[0].querySelector('a')).not.toHaveAttribute('tabindex'); + }); + }); + + describe('with the flag on', () => { + it('leaves the first work package row as the only Tab stop inside the table', async () => { + const table = await mount(true, { dragAndDropEnabled: true, columnMenuEnabled: true }); + + expect(tabbables(table.container)).toEqual([table.row('1')]); + }); + + it('leaves no native Tab stop but the first row, links included', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }], + productionMarkup: true, + configuration: { keyboardNavigationEnabled: true, columnMenuEnabled: true }, + }); + harness.injector.get(DisplayFieldService).addFieldType(AnchorDisplayField, 'text', ['String']); + await harness.render(); + + expect(harness.row('1').querySelector('a[href]')).toHaveAttribute('tabindex', '-1'); + expect(Array.from(harness.container.querySelectorAll('[tabindex="0"], a[href]:not([tabindex="-1"]), button:not([tabindex="-1"])')) + .filter((element) => !element.closest('tbody[wpInlineCreate]'))).toEqual([harness.row('1')]); + }); + + it('takes a link out of the Tab order when its href is set after render', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }], + productionMarkup: true, + configuration: { keyboardNavigationEnabled: true }, + }); + harness.injector.get(DisplayFieldService).addFieldType(LateLinkDisplayField, 'text', ['String']); + await harness.render(); + const link = harness.row('1').querySelector('td.subject a')!; + + await waitFor(() => expect(link).toHaveAttribute('href', '#')); + expect(link).toHaveAttribute('tabindex', '-1'); + }); + + it('takes the hierarchy and leaf indicators out of the Tab order', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2', ancestors: [{ id: '1' }] }], + showHierarchies: true, + configuration: { keyboardNavigationEnabled: true }, + }); + await harness.render(); + const indicators = harness.container.querySelectorAll('.wp-table--hierarchy-indicator, .wp-table--leaf-indicator'); + + expect(indicators.length).toBeGreaterThan(1); + indicators.forEach((indicator) => expect(indicator).toHaveAttribute('tabindex', '-1')); + }); + + it('drops the tabindex and button role from editable fields', async () => { + const table = await mount(true); + + expect(subjectField(table)).not.toHaveAttribute('tabindex'); + expect(subjectField(table)).not.toHaveAttribute('role'); + }); + + it('excludes the drag handle cell from the navigable cells', async () => { + const table = await mount(true, { dragAndDropEnabled: true }); + const cells = navigableCells(table.row('1')); + + expect(cells.length).toBeGreaterThan(0); + expect(cells.every((cell) => cell.classList.contains(navigableCellClassName))).toBe(true); + expect(cells.some((cell) => cell.classList.contains('wp-table--sort-td'))).toBe(false); + expect(table.row('1').querySelectorAll('td')).toHaveLength(cells.length + 1); + }); + + it('marks the header, totals and context menu cells as navigable', async () => { + const table = await mount(true, { columnMenuEnabled: true }); + + expect(table.headerCells().every((cell) => cell.classList.contains(navigableCellClassName))).toBe(true); + expect(navigableCells(table.totalsRow()).length).toBeGreaterThan(0); + expect(navigableCells(table.row('1')).at(-1)).toHaveClass('hide-when-print'); + }); + }); + + describe('isNavigableColumn', () => { + const configuration = (flags:WorkPackageTableConfigurationObject) => new WorkPackageTableConfiguration({ + actionsColumnEnabled: false, + columnMenuEnabled: false, + ...flags, + }); + + it.each([ + ['the sort column', internalSortColumn, {}, false], + ['the baseline column', internalBaselineColumn, {}, false], + ['the context column with both menus off', internalContextMenuColumn, {}, false], + ['the context column with the actions column on', internalContextMenuColumn, { actionsColumnEnabled: true }, true], + ['the context column with the column menu on', internalContextMenuColumn, { columnMenuEnabled: true }, true], + ['a query column', { id: 'subject' } as QueryColumn, {}, true], + ['a relation column', { id: 'relationsOfTypeFollows', _type: queryColumnTypes.RELATION_OF_TYPE } as QueryColumn, {}, true], + ])('%s is navigable: %s', (_name, column, flags, expected) => { + expect(isNavigableColumn(column, configuration(flags))).toBe(expected); + }); + }); + + describe('navigable cell eligibility', () => { + const groupSumsRow = (table:TableHarness) => table.container.querySelector('tbody.work-package--results-tbody tr.wp-table--sums-row')!; + + it('marks the same cells navigable in body, group sums and totals rows with drag and drop on', async () => { + const table = await mount(true, { dragAndDropEnabled: true }); + const body = navigableCells(table.row('1')); + const sums = navigableCells(groupSumsRow(table)); + const totals = navigableCells(table.totalsRow()); + + expect(body.length).toBeGreaterThan(0); + expect(sums).toHaveLength(body.length); + expect(totals).toHaveLength(body.length); + expect(groupSumsRow(table).cells[0]).not.toHaveClass(navigableCellClassName); + }); + + it('never marks the drag placeholder before the handle render pass replaces it', async () => { + const table = await mount(true, { dragAndDropEnabled: true }, { + beforeAttach: (injector) => { + vi.spyOn(injector.get(WorkPackageViewOrderService), 'positionsFor').mockReturnValue(new Promise(() => undefined)); + }, + }); + + expect(table.row('1').cells[0]).not.toHaveClass(navigableCellClassName); + }); + + it('marks the relation cells of an expanded relation row and takes its row out of the Tab order', async () => { + const table = await mount(true, {}, { + columns: ['id', 'subject', { id: 'relationsOfTypeFollows', relationType: 'follows' }], + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + groups: undefined, + }); + table.expand('1', 'relationsOfTypeFollows'); + await table.render(); + const relationRow = table.relationRow('1', '2'); + + expect(relationRow).toHaveAttribute('tabindex', '-1'); + expect(navigableCells(relationRow)).toHaveLength(navigableCells(table.row('1')).length); + expect(relationRow.querySelector('td.relationsOfTypeFollows')).toHaveClass(navigableCellClassName); + expect(relationRow.querySelector('[tabindex="0"]')).toBeNull(); + }); + + it('adds nothing when the flag is off', async () => { + const table = await mount(false, { dragAndDropEnabled: true }); + + expect(table.container.querySelector(`.${navigableCellClassName}`)).toBeNull(); + expect(table.container.querySelector('tr.wp-table--relations-additional-row[tabindex]')).toBeNull(); + }); + }); + + describe('paintRovingTabindex', () => { + it.each([ + ['a header cell', (table:TableHarness) => table.headerCells()[1]], + ['a body row', (table:TableHarness) => table.row('2')], + ['a body cell', (table:TableHarness) => navigableCells(table.row('2'))[1]], + ])('marks exactly %s', async (_name, pick) => { + const table = await mount(true); + const target = pick(table); + + paintRovingTabindex(table.container, target); + + expect(tabbables(table.container)).toEqual([target]); + expect(table.row('1')).toHaveAttribute('tabindex', '-1'); + expect(table.headerRow()).toHaveAttribute('tabindex', '-1'); + }); + + it('marks nothing when no element is focused', async () => { + const table = await mount(true); + + paintRovingTabindex(table.container, null); + + expect(tabbables(table.container)).toHaveLength(0); + }); + }); + + describe('DisplayFieldRenderer', () => { + it('stays tabbable for the details pane by default', async () => { + const table = await mount(true); + const span = new DisplayFieldRenderer(table.injector, 'single-view').render(buildWorkPackage({ id: '1' }), 'subject', null); + + expect(span).toHaveAttribute('tabindex', '0'); + expect(span).toHaveAttribute('role', 'button'); + }); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex.ts new file mode 100644 index 000000000000..e475c0a3e167 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex.ts @@ -0,0 +1,93 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; +import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { activeFieldContainerClassName } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; +import { + internalBaselineColumn, + internalContextMenuColumn, + internalSortColumn, +} from 'core-app/features/work-packages/components/wp-fast-table/builders/internal-sort-columns'; + +export const navigableCellClassName = 'wp-table--navigable-cell'; +export const inlineCreateSelector = 'tbody[wpInlineCreate]'; + +const nativeTabStopSelector = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +/** Whether `node` is in the grid: inside `root` but not in the inline-create body. */ +export function insideGrid(root:HTMLElement, node:Node|null):boolean { + if (!node || !root.contains(node)) { + return false; + } + + const element = node instanceof Element ? node : node.parentElement; + return !element?.closest(inlineCreateSelector); +} + +export function keyboardNavigationEnabled(configuration?:WorkPackageTableConfiguration):boolean { + return configuration?.keyboardNavigationEnabled ?? false; +} + +export function isNavigableColumn(column:QueryColumn, configuration:WorkPackageTableConfiguration):boolean { + switch (column.id) { + case internalSortColumn.id: + case internalBaselineColumn.id: + return false; + case internalContextMenuColumn.id: + return configuration.actionsColumnEnabled || configuration.columnMenuEnabled; + default: + return true; + } +} + +/** + * Makes `focused` the table's only Tab stop: it gets `tabindex="0"`, every + * other row, navigable cell and native Tab stop under `root` gets `tabindex="-1"`. + */ +export function paintRovingTabindex(root:HTMLElement, focused:HTMLElement|null):void { + root + .querySelectorAll(`tr, .${navigableCellClassName}`) + .forEach((element) => { + if (!element.closest(inlineCreateSelector)) { + element.setAttribute('tabindex', element === focused ? '0' : '-1'); + } + }); + + root + .querySelectorAll(nativeTabStopSelector) + .forEach((element) => { + if (element !== focused && !element.closest(inlineCreateSelector) && !element.closest(`.${activeFieldContainerClassName}`)) { + element.setAttribute('tabindex', '-1'); + } + }); +} + +/** The cells of `row` that keyboard navigation can focus, in column order. */ +export function navigableCells(row:HTMLTableRowElement):HTMLTableCellElement[] { + return Array.from(row.cells).filter((cell) => cell.classList.contains(navigableCellClassName)); +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.spec.ts index a07af444778e..a23f098aff1b 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.spec.ts @@ -91,6 +91,7 @@ describe('SelectionTransformer', () => { destroyRef: lifetime.get(DestroyRef), tableAndTimelineContainer: root, renderedRows, + configuration: { keyboardNavigationEnabled: false }, } as unknown as WorkPackageTable; new SelectionTransformer(injector, table); const row = root.querySelector('.wp-table--row')!; @@ -137,7 +138,7 @@ describe('SelectionTransformer', () => { const lifetime = Injector.create({ providers: [], parent: injector }); const root = document.createElement('div'); document.body.append(root); - new SelectionTransformer(injector, { injector, destroyRef: lifetime.get(DestroyRef), tableAndTimelineContainer: root, renderedRows: [] } as unknown as WorkPackageTable); + new SelectionTransformer(injector, { injector, destroyRef: lifetime.get(DestroyRef), tableAndTimelineContainer: root, renderedRows: [], configuration: { keyboardNavigationEnabled: false } } as unknown as WorkPackageTable); return { injector, lifetime, root, reset }; }; const first = build(); @@ -173,7 +174,7 @@ describe('SelectionTransformer', () => { const root = document.createElement('div'); root.innerHTML = '
Row
'; document.body.append(root); - new SelectionTransformer(injector, { injector, destroyRef: lifetime.get(DestroyRef), tableAndTimelineContainer: root, renderedRows: [] } as unknown as WorkPackageTable); + new SelectionTransformer(injector, { injector, destroyRef: lifetime.get(DestroyRef), tableAndTimelineContainer: root, renderedRows: [], configuration: { keyboardNavigationEnabled: false } } as unknown as WorkPackageTable); selection.initializeSelection(['2']); const allowed = fireEvent.keyDown(root.firstElementChild!, { @@ -202,7 +203,7 @@ describe('SelectionTransformer', () => { const root = document.createElement('div'); document.body.append(root); new SelectionTransformer(injector, { - injector, destroyRef: lifetime.get(DestroyRef), tableAndTimelineContainer: root, renderedRows: [], ledger, + injector, destroyRef: lifetime.get(DestroyRef), tableAndTimelineContainer: root, renderedRows: [], ledger, configuration: { keyboardNavigationEnabled: false }, } as unknown as WorkPackageTable); const stale = [{ workPackageId: '2', classIdentifier: 'wp-card-2', hidden: false }]; @@ -265,6 +266,17 @@ describe('SelectionTransformer with two tables showing the same work packages', expect(first.container.scrollTop).toBe(firstScroll); }); + it('flag off: SelectionTransformer still focuses the requested row after render', async () => { + expect(second.table.configuration.keyboardNavigationEnabled).toBe(false); + const focused = focusSpy(second); + + second.focus.updateFocus('2', true); + await second.render(); + + await waitFor(() => expect(focused).toHaveBeenCalledExactlyOnceWith(second.row('2'))); + expect(second.table.keyboardNavigation).toBeNull(); + }); + it('leaves both tables alone when its own table does not show the row', async () => { const firstScroll = first.container.scrollTop; const focusedInFirst = focusSpy(first); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts index da6ff7647d3c..ed79ba4c2df9 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts @@ -63,14 +63,16 @@ export class SelectionTransformer { if (table.ledger.generation > 0) { this.wpTableSelection.retainRendered(rows); } - this.wpTableFocus.ifShouldFocus((wpId:string) => { - const root = table.tableAndTimelineContainer; - const element = locateTableRow(wpId, root); - if (element) { - scrollTableRowIntoView(wpId, root); - this.FocusHelper.focus(element); - } - }); + if (!table.configuration.keyboardNavigationEnabled) { + this.wpTableFocus.ifShouldFocus((wpId:string) => { + const root = table.tableAndTimelineContainer; + const element = locateTableRow(wpId, root); + if (element) { + scrollTableRowIntoView(wpId, root); + this.FocusHelper.focus(element); + } + }); + } }); this.wpTableSelection.live$() diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/table-handler-registry.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/table-handler-registry.ts index 576e0b76c242..41fefdeb229b 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/table-handler-registry.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/table-handler-registry.ts @@ -59,13 +59,14 @@ import { HierarchyTransformer } from './state/hierarchy-transformer'; import { RelationsTransformer } from './state/relations-transformer'; import { RowsTransformer } from './state/rows-transformer'; import { SelectionTransformer } from './state/selection-transformer'; +import { KeyboardNavigationTransformer } from './state/keyboard/keyboard-navigation-transformer'; import { TimelineTransformer } from './state/timeline-transformer'; import { SharingTransformer, } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/sharing-transformer'; // noinspection JSUnusedLocalSymbols -type StateTransformers = new(injector:Injector, table:WorkPackageTable) => unknown; +type StateTransformers = new(injector:Injector, table:WorkPackageTable, view:TableEventComponent) => unknown; const attachedTables = new WeakSet(); @@ -104,6 +105,7 @@ export class TableHandlerRegistry extends WorkPackageViewHandlerRegistry instances.push(new Transformer(this.injector, table))); + this.stateTransformers.forEach((Transformer) => instances.push(new Transformer(this.injector, table, viewRef))); listeners = super.attachTo(viewRef); } catch (error) { table.destroy(); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts index f7a8b354968e..f49084526539 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts @@ -39,6 +39,8 @@ export interface RenderedOccurrence { readonly workPackageId:string|null; readonly renderType:'primary'|'relations'|'child_relations'; readonly hidden:boolean; + /** Stable identifier of the group for `group:*:header` and `group:*:sums` occurrences. */ + readonly groupIdentifier?:string; element:HTMLTableRowElement|null; readonly relation?:{ label:string; columnId:string; relationType:RelationColumnType }; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.spec.ts new file mode 100644 index 000000000000..855555b84ff2 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.spec.ts @@ -0,0 +1,54 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { buildTable, TableHarness } from './table-harness'; + +describe('table harness production markup', () => { + let harness:TableHarness|undefined; + + afterEach(async () => { + await harness?.destroy(); + harness = undefined; + }); + + it('renders a sticky header row with one cell per column plus the configuration cell, and a totals row', async () => { + harness = buildTable({ workPackages: [{ id: '1' }], columns: ['id', 'subject'], productionMarkup: true }); + await harness.render(); + + expect(harness.headerCells().map((th) => th.textContent?.trim())).toEqual(['id', 'subject', '']); + expect(harness.headerRow().closest('thead')).toHaveClass('-sticky'); + expect(harness.totalsRow()).toHaveClass('wp-table--sums-row'); + expect(harness.headerCells()[0].querySelector('a[href]')).not.toBeNull(); + expect(harness.headerCells()[2].querySelector('button.wp-table--columns-selection')).not.toBeNull(); + }); + + it('keyboardNavigationEnabled defaults to false and can be turned on', () => { + expect(new WorkPackageTableConfiguration({}).keyboardNavigationEnabled).toBe(false); + expect(new WorkPackageTableConfiguration({ keyboardNavigationEnabled: true }).keyboardNavigationEnabled).toBe(true); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts index 8416b8a7c18c..2574e1abb8dc 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts @@ -99,10 +99,13 @@ import { CurrentProjectService } from 'core-app/core/current-project/current-pro import { CopyToClipboardService } from 'core-app/shared/components/copy-to-clipboard/copy-to-clipboard.service'; import { DisplayFieldService } from 'core-app/shared/components/fields/display/display-field.service'; import { TextDisplayField } from 'core-app/shared/components/fields/display/field-types/text-display-field.module'; -import { EditForm } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; +import { activeFieldClassName, activeFieldContainerClassName, EditForm } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; +import { HalResourceEditFieldHandler } from 'core-app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler'; import { IntegerDisplayField } from 'core-app/shared/components/fields/display/field-types/integer-display-field.module'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; import { onDestroySafely } from 'core-app/shared/helpers/angular/owned-ui-cleanup'; +import { internalContextMenuColumn, internalSortColumn } from '../builders/internal-sort-columns'; +import { isNavigableColumn, navigableCellClassName } from '../handlers/state/keyboard/roving-tabindex'; import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; import { WorkPackageViewSelectionGesturesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection-gestures.service'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; @@ -110,13 +113,16 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service /** A relation column: `relationType` builds an `ofType` column, `children: true` the child relations column. */ export type RelationColumnSpec = { id:string, relationType:string }|{ id:string, children:true }; +/** A plain column whose header renders without a sort link. */ +export interface NonSortableColumnSpec { id:string, sortable:false } + export interface TableHarnessOptions { workPackages:WorkPackageFixture[]; providers?:Provider[]; /** Retains the root so a replacement table can mount with a fresh injector. */ dom?:ReturnType; /** Column ids, or relation column specs that render real relation rows once expanded. */ - columns?:(string|RelationColumnSpec)[]; + columns?:(string|RelationColumnSpec|NonSortableColumnSpec)[]; /** Feeds the relations service, so `ofType` columns find these relations between fixtures. */ relations?:RelationFixture[]; /** Loads fixture `children` into the resource cache on render (default); `false` leaves them for `requireAll`. */ @@ -130,14 +136,19 @@ export interface TableHarnessOptions { configuration?:WorkPackageTableConfigurationObject; /** Keeps production defaults for every setting but the extra columns the harness cannot build. */ productionDefaults?:boolean; + /** Renders the production `thead` and totals row around the results, for navigation that walks them. */ + productionMarkup?:boolean; /** Overrides for the drag action service the drop handler resolves. */ dragAction?:Partial; /** Uses the production action registry, including group and hierarchy actions. */ builtinDragActions?:boolean; query?:QueryResource; onDropComplete?:(success:boolean) => void; - /** Makes `subject` inline-editable; `formWritable: false` has the loaded form refuse the field. */ - editing?:{ formWritable?:boolean }; + /** + * Makes `fields` (default `subject`) inline-editable; `formWritable: false` has the loaded form refuse them. + * `save` stands in for the changeset save; without it every submit is an unchanged one. + */ + editing?:{ formWritable?:boolean; fields?:string[]; save?:() => Promise<{ resource:WorkPackageResource; wasNew:boolean }> }; requireDoubleClickForInlineEdit?:boolean; /** The application-wide resource cache; pass one instance to tables that share a page. */ states?:States; @@ -167,6 +178,12 @@ export interface TableHarness { row(workPackageId:string):HTMLTableRowElement; /** The relation row of `to` under `from`, located by its occurrence key. */ relationRow(from:string, to:string, type?:string):HTMLTableRowElement; + /** The `thead` row; requires `productionMarkup`. */ + headerRow():HTMLTableRowElement; + /** The header cells in column order, ending with the configuration cell; requires `productionMarkup`. */ + headerCells():HTMLTableCellElement[]; + /** The totals row; requires `productionMarkup`. */ + totalsRow():HTMLTableRowElement; timelineRow(workPackageId:string):HTMLElement; /** The timeline cell at the position of the given table row; both sides render one element per row. */ timelineRowOf(row:HTMLElement):HTMLElement; @@ -214,7 +231,7 @@ export function buildTable(options:TableHarnessOptions):TableHarness { .addFieldType(IntegerDisplayField, 'integer', ['Integer']); const querySpace = injector.get(IsolatedQuerySpace); const states = injector.get(States); - const dom = options.dom ?? buildDom(); + const dom = options.dom ?? buildDom({ productionMarkup: options.productionMarkup }); const groupBy = options.groupBy ?? 'status'; const query = options.query ?? buildQuery( @@ -225,6 +242,12 @@ export function buildTable(options:TableHarnessOptions):TableHarness { options.highlightingMode, ); querySpace.query.putValue(query); + const configuration = new WorkPackageTableConfiguration( + { ...(options.productionDefaults ? unbuildableColumns : harnessConfiguration), ...options.configuration }, + ); + if (options.productionMarkup) { + fillProductionMarkup(dom.wrapper, query, configuration); + } querySpace.groups.putValue((options.groups ?? []).map((group, index) => buildGroup(group, groupBy, index))); initializeViewServices(injector, query); @@ -235,9 +258,7 @@ export function buildTable(options:TableHarnessOptions):TableHarness { dom.tbody, dom.timelineBody, {} as WorkPackageTimelineTableController, - new WorkPackageTableConfiguration( - { ...(options.productionDefaults ? unbuildableColumns : harnessConfiguration), ...options.configuration }, - ), + configuration, ); const outputs:WorkPackageViewOutputs = { @@ -304,6 +325,18 @@ export function buildTable(options:TableHarnessOptions):TableHarness { return row; }, + headerRow() { + return productionMarkupElement(dom.wrapper, 'thead tr'); + }, + + headerCells() { + return Array.from(productionMarkupElement(dom.wrapper, 'thead tr').querySelectorAll('th')); + }, + + totalsRow() { + return productionMarkupElement(dom.wrapper, 'tr.wp-table--sums-row'); + }, + timelineRow(workPackageId) { const row = dom.timelineBody.querySelector(`.wp-timeline-cell[data-work-package-id="${workPackageId}"]`); if (!row) { @@ -375,9 +408,7 @@ export function buildTable(options:TableHarnessOptions):TableHarness { const builder = new InlineCreateRowBuilder(injector, table); table.editing.startEditing(workPackage, builder.classIdentifier(workPackage)); const [row] = builder.buildEmpty(workPackage); - const inlineCreateBody = document.createElement('tbody'); - inlineCreateBody.append(row); - dom.tbody.after(inlineCreateBody); + inlineCreateBodyOf(dom).append(row); return row; }, @@ -414,27 +445,24 @@ export class FakeDragAndDropService { } } -/** Stands in for the Angular editing portal: a plain input the edit handler can focus. */ +/** Stands in for the Angular editing portal: a plain input driven by the production field handler. */ class FakeEditingPortalService { - create(container:HTMLElement, _injector:Injector, form:EditForm, _schema:IFieldSchema, fieldName:string, _errors:string[], destroyRef?:DestroyRef):Promise { + create(container:HTMLElement, injector:Injector, form:EditForm, schema:IFieldSchema, fieldName:string, errors:string[], destroyRef?:DestroyRef):Promise { + const wrapper = document.createElement('div'); + wrapper.className = activeFieldContainerClassName; const input = document.createElement('input'); - input.className = 'inline-edit--field'; - container.appendChild(input); - const onDestroy = new Subject(); + input.className = activeFieldClassName; + wrapper.appendChild(input); + container.appendChild(wrapper); + const handler = new HalResourceEditFieldHandler(injector, form, fieldName, schema, container, injector.get(PathHelperService), errors, destroyRef); + const markErrors = () => wrapper.classList.toggle('-error', handler.isErrorenous); + markErrors(); + handler.errorsChanged$.subscribe(markErrors); let unregister:() => void = () => undefined; - const handler = { - onDestroy, - $onUserActivate: new Subject(), - focus: () => input.focus(), - deactivate: () => { - input.remove(); - delete form.activeFields[fieldName]; - onDestroy.next(); - onDestroy.complete(); - unregister(); - form.reset(fieldName); - }, - } as unknown as EditFieldHandler; + handler.onDestroy.pipe(take(1)).subscribe(() => { + wrapper.remove(); + unregister(); + }); if (destroyRef) unregister = onDestroySafely(destroyRef, () => handler.deactivate(false)); return Promise.resolve(handler); } @@ -443,7 +471,8 @@ class FakeEditingPortalService { export function harnessProviders(dragService:FakeDragAndDropService, options:TableHarnessOptions) { const editable = !!options.editing; const formWritable = options.editing?.formWritable ?? true; - const subjectSchema = (writable:boolean) => ({ type: 'String', name: 'subject', writable }); + const editableFields = options.editing?.fields ?? ['subject']; + const fieldSchema = (name:string, writable:boolean) => (editableFields.includes(name) ? { type: 'String', name, writable } : undefined); const sumsSchema = buildSumsSchema(options.groups ?? []); return [ @@ -485,9 +514,9 @@ export function harnessProviders(dragService:FakeDragAndDropService, options:Tab useValue: { state: () => ({ value: sumsSchema }), of: () => ({ - ofProperty: (attribute:string) => (attribute === 'subject' ? subjectSchema(editable) : undefined), + ofProperty: (attribute:string) => fieldSchema(attribute, editable), mappedName: (attribute:string) => attribute, - isAttributeEditable: (attribute:string) => editable && attribute === 'subject', + isAttributeEditable: (attribute:string) => editable && editableFields.includes(attribute), }), }, }, @@ -498,10 +527,12 @@ export function harnessProviders(dragService:FakeDragAndDropService, options:Tab useValue: { typedState: () => ({ hasValue: () => false, value: undefined }), stopEditing: () => undefined, + save: options.editing?.save, changeFor: () => ({ - schema: { ofProperty: (attribute:string) => (attribute === 'subject' ? subjectSchema(formWritable) : null) }, + schema: { ofProperty: (attribute:string) => fieldSchema(attribute, formWritable) ?? null }, getForm: () => Promise.resolve(), reset: () => undefined, + isEmpty: () => !options.editing?.save, }), }, }, @@ -527,7 +558,7 @@ export function harnessProviders(dragService:FakeDragAndDropService, options:Tab { provide: KeepTabService, useValue: { currentDetailsTab: 'overview', currentShowTab: 'activity' } }, { provide: FocusHelperService, useValue: { focus: () => undefined } }, { provide: WorkPackageViewBaselineService, useValue: { isActive: () => false, isChanged: () => false } }, - { provide: HalResourceNotificationService, useValue: { handleRawError: () => undefined, showEditingBlockedError: () => undefined } }, + { provide: HalResourceNotificationService, useValue: { handleRawError: () => undefined, showEditingBlockedError: () => undefined, showSave: () => undefined } }, { provide: WorkPackageNotificationService, useExisting: HalResourceNotificationService }, { provide: EditingPortalService, useValue: new FakeEditingPortalService() }, { provide: CopyToClipboardService, useValue: {} }, @@ -556,13 +587,65 @@ function relationsStub(relations:RelationFixture[]) { }; } -export function buildDom() { +function productionMarkupElement(wrapper:HTMLElement, selector:string):T { + const element = wrapper.querySelector(selector); + if (!element) { + throw new Error(`No ${selector} rendered; build the table with productionMarkup`); + } + return element; +} + +function fillProductionMarkup(wrapper:HTMLElement, query:QueryResource, configuration:WorkPackageTableConfiguration) { + const keyboardNavigationEnabled = configuration.keyboardNavigationEnabled; + const tabindex = keyboardNavigationEnabled ? ' tabindex="-1"' : ''; + const navigable = keyboardNavigationEnabled ? ' wp-table--navigable-cell' : ''; + const columns = query.columns as unknown as { id:string, name:string, sortable?:boolean }[]; + const headerCells = columns.map(({ id, name, sortable }) => ` + +
+ `).join(''); + const configurationCell = ` + + + `; + + productionMarkupElement(wrapper, 'thead tr').innerHTML = headerCells + configurationCell; + const augmentedColumns = [ + ...(configuration.dragAndDropEnabled ? [internalSortColumn] : []), + ...(columns as QueryColumn[]), + internalContextMenuColumn, + ]; + productionMarkupElement(wrapper, 'tr.wp-table--sums-row').innerHTML = augmentedColumns + .map((column) => ``) + .join(''); +} + +function inlineCreateBodyOf(dom:ReturnType):HTMLTableSectionElement { + const table = dom.tbody.closest('table')!; + const existing = table.querySelector('tbody[wpInlineCreate]'); + if (existing) { + return existing; + } + + const body = document.createElement('tbody'); + body.setAttribute('wpInlineCreate', ''); + const totalsBody = table.querySelector(':scope > tbody:has(> tr.wp-table--sums-row)'); + (totalsBody ?? dom.tbody).after(body); + return body; +} + +export function buildDom(options:{ productionMarkup?:boolean } = {}) { const wrapper = document.createElement('div'); wrapper.innerHTML = ` -
-
-
-
+
+ + ${options.productionMarkup ? '' : ''} + + ${options.productionMarkup ? '' : ''} + ${options.productionMarkup ? '' : ''} +
`; @@ -572,7 +655,7 @@ export function buildDom() { wrapper, container: wrapper.querySelector('.work-packages-tabletimeline--table-side')!, scroll: wrapper.querySelector('.work-package-table--container')!, - tbody: wrapper.querySelector('tbody')!, + tbody: wrapper.querySelector('tbody.work-package--results-tbody')!, timelineBody: wrapper.querySelector('.wp-table-timeline--body')!, }; } @@ -583,7 +666,7 @@ function buildSumsSchema(groups:GroupFixture[]):Record { return Object.fromEntries(attributes.map((name) => [name, { type: 'Integer', name, writable: false, hasDefault: false }])); } -export function buildQuery(columns:(string|RelationColumnSpec)[], groupBy:string|null, showHierarchies:boolean, timelineVisible:boolean, highlightingMode:HighlightingMode = 'inline'):QueryResource { +export function buildQuery(columns:(string|RelationColumnSpec|NonSortableColumnSpec)[], groupBy:string|null, showHierarchies:boolean, timelineVisible:boolean, highlightingMode:HighlightingMode = 'inline'):QueryResource { return { id: null, columns: columns.map(buildColumn), @@ -598,13 +681,17 @@ export function buildQuery(columns:(string|RelationColumnSpec)[], groupBy:string } as unknown as QueryResource; } -function buildColumn(column:string|RelationColumnSpec) { +function buildColumn(column:string|RelationColumnSpec|NonSortableColumnSpec) { if (typeof column === 'string') { return { id: column, name: column, _type: 'QueryColumn', href: `/api/v3/queries/columns/${column}` }; } const { id } = column; const href = `/api/v3/queries/columns/${id}`; + if ('sortable' in column) { + return { id, name: id, _type: 'QueryColumn', href, sortable: false }; + } + return 'children' in column ? { id, name: id, _type: queryColumnTypes.RELATION_CHILD, href } : { id, name: id, _type: queryColumnTypes.RELATION_OF_TYPE, relationType: column.relationType, href }; diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.spec.ts index 25f42235a920..fae01c98f1dc 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.spec.ts @@ -161,4 +161,30 @@ describe('WorkPackageTable', () => { expect(harness.table.ledger.byElement(harness.row('1'))?.key).toBe('wp:1'); }); }); + + describe('rowsRefreshed$', () => { + beforeEach(async () => { + harness = buildTable({ workPackages: [{ id: '1' }, { id: '2' }] }); + await harness.render(); + }); + + it('emits the work package id after replacing its row', () => { + const emitted:string[] = []; + harness.table.rowsRefreshed$.subscribe((id) => emitted.push(id)); + + harness.table.refreshRows(buildWorkPackage({ id: '1', subject: 'Renamed' })); + + expect(emitted).toEqual(['1']); + expect(harness.table.ledger.byWorkPackageId('1')[0].element).toBe(harness.row('1')); + }); + + it('emits nothing for an unknown work package', () => { + const emitted:string[] = []; + harness.table.rowsRefreshed$.subscribe((id) => emitted.push(id)); + + harness.table.refreshRows(buildWorkPackage({ id: '99' })); + + expect(emitted).toEqual([]); + }); + }); }); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts index fe059cebc566..8746c3c4ddf4 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts @@ -26,6 +26,7 @@ //++ import { DestroyRef, Injector } from '@angular/core'; +import { Subject } from 'rxjs'; import { runCleanup } from 'core-app/shared/helpers/angular/owned-ui-cleanup'; import { TableUiWork } from './table-ui-work'; import { I18nService } from 'core-app/core/i18n/i18n.service'; @@ -46,6 +47,7 @@ import type { PrimaryRenderPass, RenderPassOptions } from './builders/primary-re import { type OccurrenceKey, RenderedOccurrenceLedger } from './rendered-occurrence-ledger'; import { WorkPackageTableEditingContext } from './wp-table-editing'; import type { WorkPackageTableRow } from './wp-table.interfaces'; +import type { KeyboardNavigation } from './handlers/state/keyboard/keyboard-navigation-transformer'; interface PendingRender { readonly pass:PrimaryRenderPass; @@ -90,6 +92,13 @@ export class WorkPackageTable { public readonly ledger = new RenderedOccurrenceLedger(); + public keyboardNavigation:KeyboardNavigation|null = null; + + private readonly rowsRefreshedSubject = new Subject(); + + /** Emits a work package id after `refreshRows` replaced at least one of its rendered rows, or an edge row key after that row re-rendered. */ + public readonly rowsRefreshed$ = this.rowsRefreshedSubject.asObservable(); + private readonly requestedRelationTargets = new Set(); private pendingRender:PendingRender|null = null; @@ -113,12 +122,18 @@ export class WorkPackageTable { return this.destroyRef.destroyed; } + /** True between scheduling a full render and its frame commit. */ + public get hasPendingRender():boolean { + return this.pendingRender !== null; + } + public destroy():void { if (this.destroyed) return; try { this.lifetimeInjector.destroy(); } finally { runCleanup(() => this.editing.reset()); + this.rowsRefreshedSubject.complete(); } } @@ -191,6 +206,7 @@ export class WorkPackageTable { public setHidden(updates:ReadonlyMap):void { if (this.destroyed) return; if (updates.size === 0) return; + this.keyboardNavigation?.captureOwnership(); this.ledger.setHidden(updates); this.publishRendered(); } @@ -206,12 +222,23 @@ export class WorkPackageTable { return; } + this.keyboardNavigation?.captureOwnership(); + let replaced = 0; this.ledger.byWorkPackageId(workPackage.id!).forEach((occurrence) => { const replacement = pass.refresh(occurrence, workPackage); if (replacement) { this.ledger.replaceElement(occurrence.key, replacement); + replaced += 1; } }); + + if (replaced > 0) { + this.notifyRowsRefreshed(workPackage.id!); + } + } + + public notifyRowsRefreshed(key:string):void { + this.rowsRefreshedSubject.next(key); } /** @@ -224,6 +251,7 @@ export class WorkPackageTable { } private performRenderPass({ timeline }:RenderPassOptions):void { + this.keyboardNavigation?.captureOwnership(); this.editing.reset(); const promoted = timeline || this.pendingRender?.timeline === true; const pending:PendingRender = { pass: this.rowBuilder.buildRows({ timeline: promoted }), timeline: promoted }; @@ -251,10 +279,15 @@ export class WorkPackageTable { setGroupsCollapseState(newState:Record) { if (this.destroyed) return; + this.keyboardNavigation?.captureOwnership(); this.querySpace.collapsedGroups.putValue(newState); const t0 = performance.now(); - this.groupedRowsBuilder.refreshExpansionState(); + const updates = this.groupedRowsBuilder.refreshExpansionState(); + this.setHidden(updates); + if (updates.size === 0) { + this.publishRendered(); + } const t1 = performance.now(); debugLog(`Group redraw took ${t1 - t0} milliseconds.`); diff --git a/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.component.ts b/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.component.ts index 9dc733becf00..f5b5abac3f80 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.component.ts @@ -26,7 +26,7 @@ //++ import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { ChangeDetectionStrategy, Component, Injector, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Injector, inject, input } from '@angular/core'; import { OpModalService } from 'core-app/shared/components/modal/modal.service'; import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; import { WpTableConfigurationModalComponent } from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal'; @@ -47,6 +47,8 @@ export class WorkPackagesTableConfigMenuComponent { readonly opModalService = inject(OpModalService); readonly opContextMenu = inject(OPContextMenuService); + readonly keyboardNavigationEnabled = input(false); + public text = { configureTable: this.I18n.t('js.toolbar.settings.configure_view'), }; diff --git a/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.template.html b/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.template.html index a2e90971563f..76ad670ad923 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.template.html +++ b/frontend/src/app/features/work-packages/components/wp-table/config-menu/config-menu.template.html @@ -1,6 +1,7 @@