Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PER-8633: Arrow nav #300

Merged
merged 9 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 108 additions & 26 deletions src/app/file-browser/components/edit-tags/edit-tags.component.html
Original file line number Diff line number Diff line change
@@ -1,67 +1,149 @@
<ng-container *ngIf="!isDialog">
<pr-tags (focus)="startEditing()" tabindex="0" [tags]="itemTags" (click)="startEditing()" [readOnly]="false" [class.clickable]="canEdit && !isEditing"
[canEdit]="canEdit" [animate]="isEditing" [isEditing]="isEditing"></pr-tags>
<div class="input-group" *ngIf="isEditing" @ngIfScaleAnimation [class.has-tags]="allTags?.length">
<input type="text" class="new-tag form-control" [placeholder]="placeholderText" [(ngModel)]="newTagName"
(ngModelChange)="onTagType(newTagName)" (keydown.enter)="onInputEnter(newTagName)">
<pr-tags
(focus)="startEditing()"
tabindex="0"
[tags]="itemTags"
(click)="startEditing()"
[readOnly]="false"
[class.clickable]="canEdit && !isEditing"
[canEdit]="canEdit"
[animate]="isEditing"
[isEditing]="isEditing"
></pr-tags>
<div
class="input-group"
*ngIf="isEditing"
@ngIfScaleAnimation
[class.has-tags]="allTags?.length"
>
<input
type="text"
class="new-tag form-control"
[ngClass]="'new-tag-' + tagType"
[placeholder]="placeholderText"
[(ngModel)]="newTagName"
(ngModelChange)="onTagType(newTagName)"
(keydown.enter)="onInputEnter(newTagName)"
(keydown)="setFocusToFirstTagOrButton($event)"
/>
<div *ngIf="newTagInputError" class="input-vertical-error">
{{this.tagType === 'keyword' ? "Tag cannot contain ':'" : "Format must be key:value"}}
{{
this.tagType === 'keyword'
? "Tag cannot contain ':'"
: 'Format must be key:value'
}}
</div>
<div class="input-group-append">
<button class="btn btn-primary" (click)="newTagName ? onInputEnter(newTagName) : endEditing()">{{ newTagName ? 'Add' : 'Done' }}</button>
<button
class="btn btn-primary"
[ngClass]="'add-tag-' + tagType"
(click)="newTagName ? onInputEnter(newTagName) : endEditing()"
(keydown)="setFocusToFirstTagOrButton($event)"
>
{{ newTagName ? 'Add' : 'Done' }}
</button>
</div>
</div>
<div class="edit-tags" *ngIf="isEditing && matchingTags?.length" @ngIfScaleAnimation>
<div class="edit-tag" *ngFor="let tag of matchingTags" [class.is-selected]="itemTagsById.has(tag.tagId)"
(click)="onTagClick(tag)">
<div class="edit-tags" *ngIf="isEditing && matchingTags?.length">
<div
class="edit-tag"
[ngClass]="'edit-tag-' + tagType"
*ngFor="let tag of matchingTags; let i = index"
[class.is-selected]="itemTagsById.has(tag.tagId)"
(click)="onTagClick(tag)"
(keydown)="onArrowNav($event, i)"
(keydown.enter)="onTagClick(tag)"
tabindex="0"
>
<div class="select">
<div class="select-active"></div>
</div>
<div class="tag-name">
{{tag.name}}
{{ tag.name }}
</div>
</div>
</div>
<p class="manage-tags-message" *ngIf="isEditing" @ngIfScaleAnimation>
<span class="manage-tags-link" (click)="onManageTagsClick()">
Manage {{this.tagType === 'keyword' ? "tags" : 'custom metadata'}}</span> in archive settings.
Manage {{ this.tagType === 'keyword' ? 'tags' : 'custom metadata' }}</span
>
in archive settings.
</p>
</ng-container>
<ng-container *ngIf="isDialog">
<div class="dialog-content">
<div class="dialog-body">
<div class="page-title break-all">{{item.displayName}}</div>
<div class="page-title break-all">{{ item.displayName }}</div>
<div class="edit-tags-dialog-body">
<pr-tags [tags]="dialogTags" (click)="startEditing()"
[canEdit]="true" [animate]="isEditing" [isEditing]="isEditing"></pr-tags>
<pr-tags
[tags]="dialogTags"
(click)="startEditing()"
[canEdit]="true"
[animate]="isEditing"
[isEditing]="isEditing"
></pr-tags>
<div class="input-group">
<input type="text" class="new-tag form-control" [placeholder]="placeholderText" *ngIf="isEditing" @ngIfScaleAnimation
[(ngModel)]="newTagName" (keydown.enter)="onInputEnter(newTagName)" (ngModelChange)="onTagType(newTagName)">
<input
type="text"
class="new-tag form-control"
[ngClass]="'new-tag-' + tagType"
[placeholder]="placeholderText"
*ngIf="isEditing"
@ngIfScaleAnimation
[(ngModel)]="newTagName"
(keydown.enter)="onInputEnter(newTagName)"
(ngModelChange)="onTagType(newTagName)"
(keydown)="setFocusToFirstTagOrButton($event)"
/>
<div *ngIf="newTagInputError" class="input-vertical-error">
{{this.tagType === 'keyword' ? "Tag cannot contain ':'" : "Format must be key:value"}}
{{
this.tagType === 'keyword'
? "Tag cannot contain ':'"
: 'Format must be key:value'
}}
</div>
<div class="input-group-append">
<button class="btn btn-primary" (click)="onInputEnter(newTagName)" [disabled]="!newTagName">Add</button>
<button
class="btn btn-primary"
[ngClass]="'add-tag-' + tagType"
(click)="onInputEnter(newTagName)"
[disabled]="!newTagName"
(keydown)="setFocusToFirstTagOrButton($event)"
>
Add
</button>
</div>
</div>
<div class="edit-tags" *ngIf="isEditing" @ngIfScaleAnimation>
<div class="edit-tag" *ngFor="let tag of matchingTags" [class.is-selected]="itemTagsById.has(tag.tagId)"
(click)="onTagClick(tag)">
<div
class="edit-tag"
[ngClass]="'edit-tag-' + tagType"
*ngFor="let tag of matchingTags; let i = index"
[class.is-selected]="itemTagsById.has(tag.tagId)"
(click)="onTagClick(tag)"
(keydown)="onArrowNav($event, i)"
(keydown.enter)="onTagClick(tag)"
tabindex="0"
>
<div class="select">
<div class="select-active"></div>
</div>
<div class="tag-name">
{{tag.name}}
{{ tag.name }}
</div>
</div>
<div class="edit-tag" *ngIf="!matchingTags.length">No existing tags found</div>
<div class="edit-tag" *ngIf="!matchingTags.length">
No existing tags found
</div>
</div>
<p class="manage-tags-message" *ngIf="isEditing" @ngIfScaleAnimation>
<span class="manage-tags-link" (click)="onManageTagsClick()">
Manage {{this.tagType === 'keyword' ? "tags" : 'custom metadata'}}</span> in archive settings.
Manage
{{ this.tagType === 'keyword' ? 'tags' : 'custom metadata' }}</span
>
in archive settings.
</p>
</div>

</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" (click)="close()">Done</button>
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it's possible, but might it be more effective to test if the proper elements are focused in DOM rather than spying on function calls? Sometimes spies are the most effective way to do testing, but I feel like in this situation if we can test focus directly (I think through document.activeElement... but I could be unsure about that!) it'd be the best because we could refactor the function calls later and still have tests pass. I think testing focus might be tricky though, so if it isn't possible these tests are a good compromise.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* @format */
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { async } from '@angular/core/testing';
import { Shallow } from 'shallow-render';
import { Subject, Observable } from 'rxjs';
import { Observable, of } from 'rxjs';

import { ItemVO, TagVOData, RecordVO } from '@models';
import { ApiService } from '@shared/services/api/api.service';
Expand All @@ -12,6 +12,7 @@ import { SearchService } from '@search/services/search.service';
import { TagResponse } from '@shared/services/api/tag.repo';
import { Dialog } from '@root/app/dialog/dialog.module';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';
import { FileBrowserComponentsModule } from '../../file-browser-components.module';
import { EditTagsComponent, TagType } from './edit-tags.component';

Expand Down Expand Up @@ -63,7 +64,7 @@ describe('EditTagsComponent', () => {
.mock(SearchService, { getTagResults: (tag) => defaultTagList })
.mock(TagsService, {
getTags: () => defaultTagList,
getTags$: () => new Observable<TagVOData[]>(),
getTags$: () => of(defaultTagList),
setItemTags: () => {},
})
.mock(MessageService, { showError: () => {} })
Expand Down Expand Up @@ -195,4 +196,57 @@ describe('EditTagsComponent', () => {
);
expect(dialogOpenSpy.open).toHaveBeenCalled();
});

it('should highlight the correct tag on key down', async () => {
const { fixture, element } = await defaultRender();

element.componentInstance.isEditing = true;

fixture.detectChanges();
const tags = fixture.debugElement.queryAll(By.css('.edit-tag'));

const arrowKeyDown = new KeyboardEvent('keydown', { key: 'ArrowDown' });
tags[0].nativeElement.dispatchEvent(arrowKeyDown);

fixture.detectChanges();

const focusedElement = document.activeElement as HTMLElement;
expect(focusedElement).toBe(tags[1].nativeElement);
});

it('should highlight the correct tag on key up', async () => {
const { fixture, element } = await defaultRender();

element.componentInstance.isEditing = true;

fixture.detectChanges();
const tags = fixture.debugElement.queryAll(By.css('.edit-tag'));

const arrowKeyDown = new KeyboardEvent('keydown', { key: 'ArrowUp' });
tags[1].nativeElement.dispatchEvent(arrowKeyDown);

fixture.detectChanges();

const focusedElement = document.activeElement as HTMLElement;
expect(focusedElement).toBe(tags[0].nativeElement);
});

it('should highlight the input on key up', async () => {
const { fixture, element } = await defaultRender();

element.componentInstance.isEditing = true;

fixture.detectChanges();
const tag = fixture.debugElement.query(By.css('.edit-tag'));

const arrowKeyUp = new KeyboardEvent('keydown', { key: 'ArrowUp' });
const input = fixture.debugElement.query(By.css('.new-tag'));

tag.nativeElement.dispatchEvent(arrowKeyUp);

fixture.detectChanges();

const focusedElement = document.activeElement as HTMLElement;
expect(focusedElement).toEqual(input.nativeElement);
});
});
64 changes: 56 additions & 8 deletions src/app/file-browser/components/edit-tags/edit-tags.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export class EditTagsComponent
private lastDataStatus: DataStatus;
private lastFolderLinkId: number;
private dialogTagSubscription: Subscription;
private currentIndex: number = 0;

constructor(
@Optional() @Inject(DIALOG_DATA) public dialogData: any,
Expand All @@ -83,7 +84,7 @@ export class EditTagsComponent
) {
this.subscriptions.push(
this.tagsService.getTags$().subscribe((tags) => {
if (this.allTags.length > tags.length) {
if (this.allTags?.length > tags?.length) {
// The user deleted one of our tags in manage-tags.
// Let's close the editor.
this.endEditing();
Expand Down Expand Up @@ -229,15 +230,15 @@ export class EditTagsComponent
this.itemTagsById.clear();

this.itemTags = this.filterTagsByType(
this.item.TagVOs.map((tag) =>
this.allTags?.find((t) => t.tagId === tag.tagId)
).filter(
// Filter out tags that are now null from deletion
(tag) => tag?.name
)
(this.item?.TagVOs || [])
.map((tag) => this.allTags?.find((t) => t.tagId === tag.tagId))
.filter(
// Filter out tags that are now null from deletion
(tag) => tag?.name
)
);

if (!this.item.TagVOs?.length) {
if (!this.item?.TagVOs?.length) {
return;
}

Expand Down Expand Up @@ -269,4 +270,51 @@ export class EditTagsComponent
close() {
this.dialogRef.close();
}

onArrowNav(event: KeyboardEvent, index: number) {
event.stopPropagation();
if (event.key === 'ArrowDown') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably use event.stopPropagation() here, so that we don't even have to edit file-viewer.component.ts at all, as the event gets stopped before it propagates upward to parent components. We should do the same for the setFocusToFirstTagOrButton method as well.

event.preventDefault();
if (index < this.matchingTags.length - 1) {
this.currentIndex++;
this.setFocusToCurrentIndex(index + 1);
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (index > 0) {
this.setFocusToCurrentIndex(index - 1);
} else if (index === 0) {
this.setFocusToInputOrButton(`new-tag-${this.tagType}`);
}
}
}

public setFocusToInputOrButton(inputClass) {
const input = this.elementRef.nativeElement.querySelector(`.${inputClass}`);
(input as HTMLElement).focus();
}

public setFocusToFirstTagOrButton(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.setFocusToCurrentIndex(0);
}
if (event.key === 'ArrowRight') {
event.stopPropagation();
event.preventDefault();
this.setFocusToInputOrButton(`add-tag-${this.tagType}`);
}
if (event.key === 'ArrowLeft') {
event.stopPropagation();
event.preventDefault();
this.setFocusToInputOrButton(`new-tag-${this.tagType}`);
}
}

public setFocusToCurrentIndex(index) {
const elements = this.elementRef.nativeElement.querySelectorAll(
`.edit-tag-${this.tagType}`
);
(elements[index] as HTMLElement).focus();
}
}
Loading