diff --git a/apps/e2e/indicators-storybook-e2e/src/e2e/tokens.component.cy.ts b/apps/e2e/indicators-storybook-e2e/src/e2e/tokens.component.cy.ts index 103f87d526..25fb7a75ba 100644 --- a/apps/e2e/indicators-storybook-e2e/src/e2e/tokens.component.cy.ts +++ b/apps/e2e/indicators-storybook-e2e/src/e2e/tokens.component.cy.ts @@ -13,8 +13,10 @@ describe('indicators-storybook', () => { .should('exist') .should('be.visible') // Capture the focus style of the first token. - .get('sky-tokens:first-child sky-token:first-child > .sky-token') - .click({ multiple: true }) + .get( + 'sky-tokens:first-child sky-token:first-child .sky-token-btn-action' + ) + .click() .get('app-tokens') .screenshot(`tokenscomponent-tokens--tokens-${theme}`) .percySnapshot(`tokenscomponent-tokens--tokens-${theme}`, { diff --git a/apps/e2e/split-view-storybook/project.json b/apps/e2e/split-view-storybook/project.json index e1ce7e9484..c4f52533d7 100644 --- a/apps/e2e/split-view-storybook/project.json +++ b/apps/e2e/split-view-storybook/project.json @@ -32,7 +32,7 @@ { "type": "anyComponentStyle", "maximumWarning": "2kb", - "maximumError": "4kb" + "maximumError": "5kb" } ], "fileReplacements": [ diff --git a/libs/components/indicators/src/lib/modules/tokens/fixtures/token-a11y.component.fixture.html b/libs/components/indicators/src/lib/modules/tokens/fixtures/token-a11y.component.fixture.html new file mode 100644 index 0000000000..a17b3bfae0 --- /dev/null +++ b/libs/components/indicators/src/lib/modules/tokens/fixtures/token-a11y.component.fixture.html @@ -0,0 +1,13 @@ + + + diff --git a/libs/components/indicators/src/lib/modules/tokens/fixtures/token-a11y.component.fixture.ts b/libs/components/indicators/src/lib/modules/tokens/fixtures/token-a11y.component.fixture.ts new file mode 100644 index 0000000000..82d408fef6 --- /dev/null +++ b/libs/components/indicators/src/lib/modules/tokens/fixtures/token-a11y.component.fixture.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; + +import { SkyToken } from '../types/token'; + +@Component({ + selector: 'sky-token-a11y-test', + templateUrl: './token-a11y.component.fixture.html', +}) +export class SkyTokenA11yTestComponent { + public data: SkyToken<{ name: string }>[] = [ + { + value: { name: 'Apple' }, + }, + { + value: { name: 'Orange' }, + }, + { + value: { name: 'Strawberry' }, + }, + ]; +} diff --git a/libs/components/indicators/src/lib/modules/tokens/fixtures/tokens-fixtures.module.ts b/libs/components/indicators/src/lib/modules/tokens/fixtures/tokens-fixtures.module.ts index 8cf7bb88ce..8d2b7a21f8 100644 --- a/libs/components/indicators/src/lib/modules/tokens/fixtures/tokens-fixtures.module.ts +++ b/libs/components/indicators/src/lib/modules/tokens/fixtures/tokens-fixtures.module.ts @@ -4,12 +4,21 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SkyTokensModule } from '../tokens.module'; +import { SkyTokenA11yTestComponent } from './token-a11y.component.fixture'; import { SkyTokenTestComponent } from './token.component.fixture'; import { SkyTokensTestComponent } from './tokens.component.fixture'; @NgModule({ - declarations: [SkyTokenTestComponent, SkyTokensTestComponent], + declarations: [ + SkyTokenA11yTestComponent, + SkyTokenTestComponent, + SkyTokensTestComponent, + ], imports: [CommonModule, NoopAnimationsModule, SkyTokensModule], - exports: [SkyTokenTestComponent, SkyTokensTestComponent], + exports: [ + SkyTokenA11yTestComponent, + SkyTokenTestComponent, + SkyTokensTestComponent, + ], }) export class SkyTokensFixturesModule {} diff --git a/libs/components/indicators/src/lib/modules/tokens/token.component.html b/libs/components/indicators/src/lib/modules/tokens/token.component.html index dfcb38dbd7..eab6cf408d 100644 --- a/libs/components/indicators/src/lib/modules/tokens/token.component.html +++ b/libs/components/indicators/src/lib/modules/tokens/token.component.html @@ -1,39 +1,69 @@
- - + + - - - - + + + + +
diff --git a/libs/components/indicators/src/lib/modules/tokens/token.component.scss b/libs/components/indicators/src/lib/modules/tokens/token.component.scss index b99999c261..390f100baa 100644 --- a/libs/components/indicators/src/lib/modules/tokens/token.component.scss +++ b/libs/components/indicators/src/lib/modules/tokens/token.component.scss @@ -4,21 +4,25 @@ .sky-token { background-color: $sky-background-color-info-light; border: 1px solid $sky-highlight-color-info; - overflow: hidden; - padding: 2px 8px; + padding: 0; display: inline-block; - user-select: none; &:hover, - &:focus { + &.sky-token-focused { background-color: darken($sky-background-color-info-light, 10%); border-color: darken($sky-highlight-color-info, 10%); cursor: pointer; } - &:focus { + &.sky-token-focused { @include mixins.sky-field-status('active'); } + + &.sky-btn-disabled { + .sky-btn-disabled { + opacity: 1; + } + } } .sky-btn-disabled { @@ -26,19 +30,34 @@ user-select: none; } -.sky-token-btn-close { - background: transparent; - padding: 0; +.sky-token-btn { + background-color: transparent; border: 0; - margin-left: 2px; + padding: 2px 8px; +} + +.sky-token-btn-action { + &:focus-visible { + outline: none; + } +} + +.sky-token-btn-close { + margin-left: -2px; opacity: 0.9; &:hover, - &:focus { + &:focus-visible { opacity: 1; } } +.sky-token-dismissible { + .sky-token-btn-action { + padding-right: 0; + } +} + .sky-token-btn-close-icon-modern { display: none; } @@ -47,19 +66,18 @@ .sky-token { align-items: center; display: inline-flex; - padding: 1px 5px; font-size: 14px; - &.sky-btn-disabled { - color: $sky-text-color-default; - } - &:not(.sky-btn-disabled) { @include mixins.sky-theme-modern-border($sky-background-color-info); } - &.sky-token-dismissable { + &.sky-token-dismissible { padding-right: 1px; + + .sky-token-btn-action { + padding-right: 0; + } } &:hover:not(:active) { @@ -70,16 +88,31 @@ @include mixins.sky-theme-modern-border-active; } - &:focus:not(:active) { + &.sky-token-focused:not(:active) { @include mixins.sky-theme-modern-border-focus; } - &:focus, + &.sky-token-focused, &:hover { background-color: $sky-background-color-info-light; } } + .sky-token-btn { + padding: 1px 5px; + border: 0; + box-shadow: none; + background-color: transparent; + + &.sky-btn-disabled { + color: $sky-text-color-default; + } + } + + .sky-token-btn-action { + font-size: 14px; + } + .sky-token-btn-close { align-items: center; border-radius: $sky-theme-modern-border-radius-md - 1; @@ -87,6 +120,7 @@ height: 20px; margin-left: $sky-theme-modern-margin-inline-xs; width: 20px; + padding: 0; &:hover { @include mixins.sky-theme-modern-border-hover(transparent); @@ -96,7 +130,7 @@ @include mixins.sky-theme-modern-border-active; } - &:focus { + &:focus-visible { outline: none; &:not(:active) { diff --git a/libs/components/indicators/src/lib/modules/tokens/token.component.spec.ts b/libs/components/indicators/src/lib/modules/tokens/token.component.spec.ts index e03ddd8ac7..7f5733d59b 100644 --- a/libs/components/indicators/src/lib/modules/tokens/token.component.spec.ts +++ b/libs/components/indicators/src/lib/modules/tokens/token.component.spec.ts @@ -42,6 +42,37 @@ describe('Token component', () => { validateActive('sky-token-btn-close'); }); + it('should emit when token focused', () => { + const fixture = TestBed.createComponent(SkyTokenComponent); + const tokenEl = fixture.nativeElement.querySelector('.sky-token'); + + const focusSpy = spyOn( + fixture.componentInstance.tokenFocus, + 'emit' + ).and.callThrough(); + + fixture.componentInstance.focusable = true; + fixture.detectChanges(); + + SkyAppTestUtility.fireDomEvent(tokenEl, 'focusin'); + fixture.detectChanges(); + + // Ensure CSS class is added on focus. + expect(tokenEl).toHaveClass('sky-token-focused'); + expect(focusSpy).toHaveBeenCalled(); + + SkyAppTestUtility.fireDomEvent(tokenEl, 'focusout', { + customEventInit: { + // Mock an element that is not a child of the token. + relatedTarget: document.createElement('div'), + }, + }); + fixture.detectChanges(); + + // Ensure CSS class is removed on blur. + expect(tokenEl).not.toHaveClass('sky-token-focused'); + }); + it('should use the specified ARIA label', () => { const fixture = TestBed.createComponent(SkyTokenTestComponent); @@ -59,4 +90,12 @@ describe('Token component', () => { expect(btnEl.getAttribute('aria-label')).toBe('Remove item'); }); + + it('should not have a role by default', () => { + const fixture = TestBed.createComponent(SkyTokenTestComponent); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('.sky-token').getAttribute('role') + ).toBeNull(); + }); }); diff --git a/libs/components/indicators/src/lib/modules/tokens/token.component.ts b/libs/components/indicators/src/lib/modules/tokens/token.component.ts index af0235ad37..e68d926984 100644 --- a/libs/components/indicators/src/lib/modules/tokens/token.component.ts +++ b/libs/components/indicators/src/lib/modules/tokens/token.component.ts @@ -5,8 +5,8 @@ import { EventEmitter, Input, Output, + ViewChild, } from '@angular/core'; -import { SkyLibResourcesService } from '@skyux/i18n'; @Component({ selector: 'sky-token', @@ -36,13 +36,7 @@ export class SkyTokenComponent { * @default "Remove item" */ @Input() - public set ariaLabel(value: string | undefined) { - this.#ariaLabelOrDefault = value || this.#getDefaultAriaLabel(); - } - - public get ariaLabel(): string { - return this.#ariaLabelOrDefault; - } + public ariaLabel: string | undefined; /** * Indicates whether users can remove the token from the list by selecting the close button. @@ -67,6 +61,13 @@ export class SkyTokenComponent { this.tabIndex = value !== false ? 0 : -1; } + /** + * Used by the tokens component to set the appropriate role for each token. + * @internal + */ + @Input() + public role: string | undefined; + /** * Fires when users click the close button. */ @@ -79,22 +80,34 @@ export class SkyTokenComponent { @Output() public tokenFocus = new EventEmitter(); + @ViewChild('actionButton', { read: ElementRef, static: true }) + private actionButtonRef: ElementRef | undefined; + + public isFocused = false; public tokenActive = false; public closeActive = false; public tabIndex = 0; - #ariaLabelOrDefault: string; #elementRef: ElementRef; - #resourcesSvc: SkyLibResourcesService; #_disabled = false; #_dismissible = true; - constructor(elementRef: ElementRef, resourcesSvc: SkyLibResourcesService) { + constructor(elementRef: ElementRef) { this.#elementRef = elementRef; - this.#resourcesSvc = resourcesSvc; + } + + protected onFocusIn(): void { + if (!this.isFocused) { + this.tokenFocus.emit(); + this.isFocused = true; + } + } - this.#ariaLabelOrDefault = this.#getDefaultAriaLabel(); + protected onFocusOut(event: FocusEvent): void { + this.isFocused = this.#elementRef.nativeElement.contains( + event.relatedTarget + ); } public dismissToken(event: Event): void { @@ -103,7 +116,7 @@ export class SkyTokenComponent { } public focusElement(): void { - this.#elementRef.nativeElement.querySelector('.sky-token').focus(); + this.actionButtonRef?.nativeElement.focus(); } public setTokenActive(tokenActive: boolean): void { @@ -113,12 +126,4 @@ export class SkyTokenComponent { public setCloseActive(closeActive: boolean): void { this.closeActive = closeActive; } - - #getDefaultAriaLabel(): string { - // TODO: Need to implement the async `getString` method in a breaking change. - return this.#resourcesSvc.getStringForLocale( - { locale: 'en-US' }, - 'skyux_tokens_dismiss_button_title' - ); - } } diff --git a/libs/components/indicators/src/lib/modules/tokens/tokens.component.html b/libs/components/indicators/src/lib/modules/tokens/tokens.component.html index ba00ed63c3..d532a6360f 100644 --- a/libs/components/indicators/src/lib/modules/tokens/tokens.component.html +++ b/libs/components/indicators/src/lib/modules/tokens/tokens.component.html @@ -1,11 +1,11 @@
{ fixture.detectChanges(); expect(component.tokensComponent?.activeIndex).toEqual(1); expect(document.activeElement).toEqual( - tokenElements.item(1).querySelector('.sky-token') + tokenElements.item(1).querySelector('.sky-token-btn-action') ); SkyAppTestUtility.fireDomEvent(tokenElements.item(1), 'keydown', { @@ -47,7 +48,7 @@ describe('Tokens component', () => { expect(component.tokensComponent?.activeIndex).toEqual(0); expect(document.activeElement).toEqual( - tokenElements.item(0).querySelector('.sky-token') + tokenElements.item(0).querySelector('.sky-token-btn-action') ); } @@ -58,7 +59,9 @@ describe('Tokens component', () => { component.messageStream?.next({ type }); fixture.detectChanges(); - const tokenElements = fixture.nativeElement.querySelectorAll('.sky-token'); + const tokenElements = fixture.nativeElement.querySelectorAll( + '.sky-token-btn-action' + ); const focusedToken = tokenElements[index] as HTMLElement; expect(component.tokensComponent?.activeIndex).toEqual(index); @@ -318,8 +321,9 @@ describe('Tokens component', () => { fixture.detectChanges(); - const tokenElements = - fixture.nativeElement.querySelectorAll('.sky-token'); + const tokenElements = fixture.nativeElement.querySelectorAll( + '.sky-token-btn-action' + ); const lastToken = tokenElements[tokenElements.length - 1] as HTMLElement; expect(component.tokensComponent?.activeIndex).toEqual( @@ -464,22 +468,40 @@ describe('Tokens component', () => { component.publishTokens(); fixture.detectChanges(); - let tokenDivs: NodeListOf = + let tokenButtons: NodeListOf = component.tokensElementRef?.nativeElement.querySelectorAll( - '.sky-token' + '.sky-token-btn-action' ); - expect(tokenDivs.item(0).tabIndex).toEqual(0); + expect(tokenButtons.item(0).tabIndex).toEqual(0); component.focusable = false; fixture.detectChanges(); - tokenDivs = + tokenButtons = component.tokensElementRef?.nativeElement.querySelectorAll( '.sky-token' ); - expect(tokenDivs.item(0).tabIndex).toEqual(-1); + expect(tokenButtons.item(0).tabIndex).toEqual(-1); await expectAsync(fixture.nativeElement).toBeAccessible(); }); }); + + it('should be accessible', async () => { + const a11yFixture = TestBed.createComponent(SkyTokenA11yTestComponent); + + a11yFixture.detectChanges(); + + await expectAsync( + a11yFixture.nativeElement.querySelector( + '[data-sky-id="non-dismissible-tokens"]' + ) + ).toBeAccessible(); + + await expectAsync( + a11yFixture.nativeElement.querySelector( + '[data-sky-id="dismissible-tokens"]' + ) + ).toBeAccessible(); + }); }); diff --git a/libs/components/indicators/src/lib/modules/tokens/tokens.component.ts b/libs/components/indicators/src/lib/modules/tokens/tokens.component.ts index 7c4cf9811d..cf925e2761 100644 --- a/libs/components/indicators/src/lib/modules/tokens/tokens.component.ts +++ b/libs/components/indicators/src/lib/modules/tokens/tokens.component.ts @@ -221,11 +221,12 @@ export class SkyTokensComponent implements OnDestroy { constructor(changeDetector: ChangeDetectorRef) { this.#changeDetector = changeDetector; + this.#initMessageStream(); // Angular calls the trackBy function without applying the component instance's scope. // Use a fat-arrow function so the current component instance's trackWith property can // be referenced. - this.trackTokenFn = (_index, item) => { + this.trackTokenFn = (_index, item): unknown => { if (this.trackWith) { return item.value[this.trackWith]; } diff --git a/libs/components/indicators/testing/src/tokens/token-harness.ts b/libs/components/indicators/testing/src/tokens/token-harness.ts index 000c0fae9c..997f053d33 100644 --- a/libs/components/indicators/testing/src/tokens/token-harness.ts +++ b/libs/components/indicators/testing/src/tokens/token-harness.ts @@ -11,6 +11,8 @@ export class SkyTokenHarness extends ComponentHarness { */ public static hostSelector = 'sky-token'; + #getActionButton = this.locatorFor('button.sky-token-btn-action'); + #getDismissButton = this.locatorFor('button.sky-token-btn-close'); #getWrapper = this.locatorFor('.sky-token'); @@ -50,7 +52,7 @@ export class SkyTokenHarness extends ComponentHarness { public async dismiss(): Promise { if (!(await this.isDismissible())) { throw new Error( - 'Could not dismiss the token because it is not dismissable.' + 'Could not dismiss the token because it is not dismissible.' ); } @@ -68,20 +70,20 @@ export class SkyTokenHarness extends ComponentHarness { * Whether the token is disabled. */ public async isDisabled(): Promise { - return (await this.#getWrapper()).hasClass('sky-btn-disabled'); + return (await this.#getWrapper()).hasClass('sky-token-disabled'); } /** * Whether the token is dismissible. */ public async isDismissible(): Promise { - return (await this.#getWrapper()).hasClass('sky-token-dismissable'); + return (await this.#getWrapper()).hasClass('sky-token-dismissible'); } /** * Whether the token is focused. */ public async isFocused(): Promise { - return (await this.#getWrapper()).isFocused(); + return (await this.#getActionButton()).isFocused(); } } diff --git a/libs/components/indicators/testing/src/tokens/tokens-harness.spec.ts b/libs/components/indicators/testing/src/tokens/tokens-harness.spec.ts index 852a236cc7..ce5d3c7ce7 100644 --- a/libs/components/indicators/testing/src/tokens/tokens-harness.spec.ts +++ b/libs/components/indicators/testing/src/tokens/tokens-harness.spec.ts @@ -89,7 +89,7 @@ describe('Tokens harness', () => { await expectAsync(tokens[0].isDismissible()).toBeResolvedTo(false); await expectAsync(tokens[0].dismiss()).toBeRejectedWithError( - 'Could not dismiss the token because it is not dismissable.' + 'Could not dismiss the token because it is not dismissible.' ); }); @@ -147,7 +147,9 @@ describe('Tokens harness', () => { await expectAsync(firstToken.isFocused()).toBeResolvedTo(false); - fixture.nativeElement.querySelectorAll('sky-token .sky-btn')[0].focus(); + fixture.nativeElement + .querySelectorAll('sky-token .sky-token-btn')[0] + .focus(); await expectAsync(firstToken.isFocused()).toBeResolvedTo(true); }); diff --git a/libs/components/lookup/src/lib/modules/lookup/lookup.component.spec.ts b/libs/components/lookup/src/lib/modules/lookup/lookup.component.spec.ts index e1e7cb9dac..5734bd9e2b 100644 --- a/libs/components/lookup/src/lib/modules/lookup/lookup.component.spec.ts +++ b/libs/components/lookup/src/lib/modules/lookup/lookup.component.spec.ts @@ -106,14 +106,14 @@ describe('Lookup component', function () { if (async) { ( document.querySelectorAll( - '#my-async-lookup .sky-lookup-tokens .sky-token' + '#my-async-lookup .sky-lookup-tokens .sky-token-btn-action' )[index] as HTMLElement ).click(); } else { ( - document.querySelectorAll('#my-lookup .sky-lookup-tokens .sky-token')[ - index - ] as HTMLElement + document.querySelectorAll( + '#my-lookup .sky-lookup-tokens .sky-token-btn-action' + )[index] as HTMLElement ).click(); } fixture.detectChanges(); @@ -301,9 +301,11 @@ describe('Lookup component', function () { function getTokenElements(async: boolean = false): NodeListOf { if (async) { - return document.querySelectorAll('#my-async-lookup .sky-token'); + return document.querySelectorAll( + '#my-async-lookup .sky-token-btn-action' + ); } else { - return document.querySelectorAll('#my-lookup .sky-token'); + return document.querySelectorAll('#my-lookup .sky-token-btn-action'); } } @@ -3486,10 +3488,9 @@ describe('Lookup component', function () { performSearch('s', fixture); selectSearchResult(0, fixture); - const tokenElements = getTokenElements(); const input = getInputElement(lookupComponent); - triggerClick(tokenElements.item(0), fixture, true); + clickToken(0, fixture, false); expect(document.activeElement).not.toEqual(input); })); @@ -6446,10 +6447,9 @@ describe('Lookup component', function () { performSearch('s', fixture); selectSearchResult(0, fixture); - const tokenElements = getTokenElements(); const input = getInputElement(lookupComponent); - triggerClick(tokenElements.item(0), fixture, true); + clickToken(0, fixture, false); expect(document.activeElement).not.toEqual(input); })); @@ -6537,10 +6537,9 @@ describe('Lookup component', function () { performSearch('s', fixture); selectSearchResult(0, fixture); - const tokenElements = getTokenElements(); const input = getInputElement(lookupComponent); - triggerClick(tokenElements.item(0), fixture, true); + clickToken(0, fixture, false); expect(document.activeElement).not.toEqual(input); })); diff --git a/libs/components/select-field/src/lib/modules/select-field/select-field.component.spec.ts b/libs/components/select-field/src/lib/modules/select-field/select-field.component.spec.ts index d3db3624f5..a584e5e591 100644 --- a/libs/components/select-field/src/lib/modules/select-field/select-field.component.spec.ts +++ b/libs/components/select-field/src/lib/modules/select-field/select-field.component.spec.ts @@ -97,7 +97,7 @@ describe('Select field component', () => { function closeToken(index: number) { const tokens = getTokens(); - tokens.item(index).querySelector('button').click(); + tokens.item(index).querySelector('button.sky-token-btn-close').click(); tick(); fixture.detectChanges(); }