Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Component,
signal,
} from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
DeferBlockFixture,
DeferBlockState,
TestBed,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';

import { DaffRovingTabIndexBoundaryDirective } from './roving-tab-index-boundary.directive';
import { DaffRovingTabIndexDirective } from './roving-tab-index.directive';

@Component({
template: `
<div class="boundary" [rtiBoundary]="groupValue()">
@defer (hydrate on interaction) {
<div class="child" rti></div>
}
</div>
`,
imports: [
DaffRovingTabIndexBoundaryDirective,
DaffRovingTabIndexDirective,
],
})
class WrapperComponent {
groupValue = signal('group');
}

describe('@daffodil/design/core | Roving Tab Index | Incremental Hydration', () => {
let wrapper: WrapperComponent;
let fixture: ComponentFixture<WrapperComponent>;
let deferBlockFixture: DeferBlockFixture;

beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
WrapperComponent,
],
deferBlockBehavior: DeferBlockBehavior.Manual,
});

fixture = TestBed.createComponent(WrapperComponent);
wrapper = fixture.componentInstance;
fixture.detectChanges();
deferBlockFixture = (await fixture.getDeferBlocks())[0];
});

// not functional as a mocked test, needs e2e to properly test
xit('should update the child to match the parent after deferred hydration', async () => {
await deferBlockFixture.render(DeferBlockState.Complete);

wrapper.groupValue.set('updated-group');
await fixture.whenStable();

const child = fixture.debugElement.query(By.css('.child'));

expect(child.attributes['data-rti']).toEqual('updated-group');
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { CdkTrapFocus } from '@angular/cdk/a11y';
import {
computed,
Directive,
effect,
forwardRef,
inject,
input,
} from '@angular/core';

Expand All @@ -25,7 +24,6 @@ import { DaffRovingTabIndexDirective } from './roving-tab-index.directive';
'(keydown.space)': 'enterGroup($event)',
},
hostDirectives: [
CdkTrapFocus,
DaffRovingTabIndexDirective,
],
providers: [
Expand All @@ -36,22 +34,19 @@ import { DaffRovingTabIndexDirective } from './roving-tab-index.directive';
],
})
export class DaffRovingTabIndexBoundaryDirective implements DaffRovingTabIndexBoundary {
/**
* Don't touch this directly. Use `_uniqueId`.
*/
private static _uniqueIdCounter = 0;

/**
* Don't touch this directly. Use `_uniqueId`.
*/
private _cachedUniqueId: string | undefined;
private get _uniqueId(): string {
if (!this._cachedUniqueId) {
this._cachedUniqueId = `ε-rtiBoundary-${DaffRovingTabIndexBoundaryDirective._uniqueIdCounter++}`;
this._cachedUniqueId = `ε-rtiBoundary-${crypto.randomUUID()}`;
}
return this._cachedUniqueId;
}

private readonly groupService = inject(DaffRovingTabIndexService);

/**
* The name of the group for which that this element will act as boundary.
* Optional, will be autogenerated to a unique name if omitted.
Expand All @@ -62,15 +57,6 @@ export class DaffRovingTabIndexBoundaryDirective implements DaffRovingTabIndexBo
*/
readonly effectiveBoundary = computed(() => this.rtiBoundary() || this._uniqueId);

constructor(
private groupService: DaffRovingTabIndexService,
private focusTrap: CdkTrapFocus,
) {
effect(() => {
this.focusTrap.enabled = this.effectiveBoundary() === this.groupService.group();
});
}

/**
* @docs-private
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,4 @@ export class DaffRovingTabIndexService {
: ary[index === ary.length - 1 ? 0 : index + 1]).focus();
}
}

onKeydown(evt: Event) {
if ('key' in evt) {
switch ((<KeyboardEvent>evt).key) {
case 'ArrowUp':
case 'ArrowDown':
if (this._group()) {
evt.preventDefault();
const ary = Array.from(this.document.querySelectorAll<HTMLElement>(`[data-rti="${this._group()}"]`));
const index = ary.findIndex((el) => el === this.document.activeElement);
(<HTMLElement>this.document.activeElement).blur();
((<KeyboardEvent>evt).key === 'ArrowUp'
? ary[index === 0 ? ary.length - 1 : index - 1]
: ary[index === ary.length - 1 ? 0 : index + 1]).focus();
}
break;

default:
break;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ describe('@daffodil/design | DaffRovingTabIndexDirective', () => {
expect(directive.group()).toEqual('');
});

describe('when the escape key is pressed', () => {
describe('when the escape key is pressed when inside a group', () => {
beforeEach(() => {
groupSpy.set('test');
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
fixture.detectChanges();
});
Expand All @@ -89,8 +90,9 @@ describe('@daffodil/design | DaffRovingTabIndexDirective', () => {
});
});

describe('when the up arrow is pressed', () => {
describe('when the up arrow is pressed when inside a group', () => {
beforeEach(() => {
groupSpy.set('test');
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
fixture.detectChanges();
});
Expand All @@ -100,8 +102,9 @@ describe('@daffodil/design | DaffRovingTabIndexDirective', () => {
});
});

describe('when the down arrow is pressed', () => {
describe('when the down arrow is pressed when inside a group', () => {
beforeEach(() => {
groupSpy.set('test');
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
fixture.detectChanges();
});
Expand All @@ -110,6 +113,74 @@ describe('@daffodil/design | DaffRovingTabIndexDirective', () => {
expect(serviceSpy.next).toHaveBeenCalledWith();
});
});

describe('when the up arrow is pressed when not inside a group', () => {
beforeEach(() => {
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
fixture.detectChanges();
});

it('should not navigate to the previous target', () => {
expect(serviceSpy.previous).not.toHaveBeenCalled();
});
});

describe('when the down arrow is pressed when not inside a group', () => {
beforeEach(() => {
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
fixture.detectChanges();
});

it('should not navigate to the next target', () => {
expect(serviceSpy.next).not.toHaveBeenCalled();
});
});

describe('when the tab key is pressed when inside a group', () => {
beforeEach(() => {
groupSpy.set('test');
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
fixture.detectChanges();
});

it('should navigate to the next target', () => {
expect(serviceSpy.next).toHaveBeenCalledWith();
});
});

describe('when the tab plus shift key is pressed when inside a group', () => {
beforeEach(() => {
groupSpy.set('test');
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }));
fixture.detectChanges();
});

it('should navigate to the previous target', () => {
expect(serviceSpy.previous).toHaveBeenCalledWith();
});
});

describe('when the tab key is pressed when not inside a group', () => {
beforeEach(() => {
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
fixture.detectChanges();
});

it('should not navigate to the previous target', () => {
expect(serviceSpy.previous).not.toHaveBeenCalled();
});
});

describe('when the tab plus shift key is pressed when not inside a group', () => {
beforeEach(() => {
(<HTMLElement>de.nativeElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }));
fixture.detectChanges();
});

it('should not navigate to the next target', () => {
expect(serviceSpy.next).not.toHaveBeenCalled();
});
});
});

@Component({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
afterNextRender,
computed,
Directive,
Inject,
input,
Optional,
signal,
SkipSelf,
} from '@angular/core';

Expand All @@ -26,9 +28,13 @@ import { DaffRovingTabIndexService } from './roving-tab-index-group.service';
'(keydown.escape)': 'leaveGroup($event)',
'(keydown.arrowup)': 'previous($event)',
'(keydown.arrowdown)': 'next($event)',
'(keydown.tab)': 'next($event)',
'(keydown.shift.tab)': 'previous($event)',
},
})
export class DaffRovingTabIndexDirective {
private readonly _hydrationWorkaround = signal(false);

/**
* Allows the RTI group to be overriden.
* By default it will be the nearest ancestor or the default root group if no boundary ancestor exists.
Expand All @@ -39,7 +45,11 @@ export class DaffRovingTabIndexDirective {
* The group in which this RTI target resides.
* See {@link DaffRovingTabIndexBoundaryDirective} to make an element act as the boundary of an RTI group.
*/
readonly group = computed(() => this.rti() || this.parent?.effectiveBoundary() || '');
readonly group = computed(() =>
this._hydrationWorkaround()
? this.rti() || this.parent?.effectiveBoundary() || ''
: this.rti() || this.parent?.effectiveBoundary() || '',
);
/**
* @docs-private
*/
Expand All @@ -52,29 +62,42 @@ export class DaffRovingTabIndexDirective {
constructor(
private service: DaffRovingTabIndexService,
@Optional() @SkipSelf() @Inject(DAFF_ROVING_TAB_INDEX_BOUNDARY) private parent: DaffRovingTabIndexBoundary,
) {}
) {
afterNextRender({
read: () => this._hydrationWorkaround.set(true),
});
}

/**
* @docs-private
*/
leaveGroup(evt: Event) {
evt.stopPropagation();
this.service.leave();
protected leaveGroup(evt: Event) {
if (this.service.group()) {
evt.preventDefault();
evt.stopPropagation();
this.service.leave();
}
}

/**
* @docs-private
*/
next(evt: Event) {
evt.stopPropagation();
this.service.next();
protected next(evt: Event) {
if (this.service.group()) {
evt.preventDefault();
evt.stopPropagation();
this.service.next();
}
}

/**
* @docs-private
*/
previous(evt: Event) {
evt.stopPropagation();
this.service.previous();
protected previous(evt: Event) {
if (this.service.group()) {
evt.preventDefault();
evt.stopPropagation();
this.service.previous();
}
}
}
Loading