From 867180cce333d6db967f73acbc30bfc395767073 Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Tue, 14 May 2024 18:30:03 -0500 Subject: [PATCH 01/17] Allow tests copied from FAST to use DOM --- packages/eslint-config-nimble/typescript.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/eslint-config-nimble/typescript.js b/packages/eslint-config-nimble/typescript.js index 8141b53c64..9154011cd8 100644 --- a/packages/eslint-config-nimble/typescript.js +++ b/packages/eslint-config-nimble/typescript.js @@ -111,6 +111,15 @@ module.exports = { ] } }, + { + files: ['*.foundation.spec.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { paths: restrictedImportsPaths() } + ] + } + }, { files: ['styles.ts'], rules: { From 58bd3e2e0c9285553b0fc921b93580f5aa865923 Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Wed, 15 May 2024 12:47:27 -0500 Subject: [PATCH 02/17] Add tabindex forwarding for button --- .../nimble-components/src/button/index.ts | 12 +- .../nimble-components/src/button/template.ts | 59 ++ .../button/tests/button.foundation.spec.ts | 669 ++++++++++++++++++ .../src/button/tests/button.spec.ts | 45 ++ 4 files changed, 783 insertions(+), 2 deletions(-) create mode 100644 packages/nimble-components/src/button/template.ts create mode 100644 packages/nimble-components/src/button/tests/button.foundation.spec.ts diff --git a/packages/nimble-components/src/button/index.ts b/packages/nimble-components/src/button/index.ts index ad5c7f467e..8b702638f8 100644 --- a/packages/nimble-components/src/button/index.ts +++ b/packages/nimble-components/src/button/index.ts @@ -1,8 +1,7 @@ -import { attr } from '@microsoft/fast-element'; +import { attr, nullableNumberConverter } from '@microsoft/fast-element'; import { Button as FoundationButton, ButtonOptions, - buttonTemplate as template, DesignSystem } from '@microsoft/fast-foundation'; import type { @@ -10,6 +9,7 @@ import type { ButtonAppearanceVariantPattern } from '../patterns/button/types'; import { styles } from './styles'; +import { template } from './template'; import { ButtonAppearance, ButtonAppearanceVariant } from './types'; declare global { @@ -47,6 +47,14 @@ export class Button */ @attr({ attribute: 'content-hidden', mode: 'boolean' }) public contentHidden = false; + + /** + * @public + * @remarks + * HTML Attribute: tabindex + */ + @attr({ attribute: 'tabindex', converter: nullableNumberConverter }) + public override tabIndex!: number; } /** diff --git a/packages/nimble-components/src/button/template.ts b/packages/nimble-components/src/button/template.ts new file mode 100644 index 0000000000..fabe485441 --- /dev/null +++ b/packages/nimble-components/src/button/template.ts @@ -0,0 +1,59 @@ +import { html, ref, slotted } from '@microsoft/fast-element'; +import type { ViewTemplate } from '@microsoft/fast-element'; +import { + ButtonOptions, + endSlotTemplate, + FoundationElementTemplate, + startSlotTemplate +} from '@microsoft/fast-foundation'; +import type { Button } from '.'; + +export const template: FoundationElementTemplate< +ViewTemplate +`; diff --git a/packages/nimble-components/src/button/tests/button.foundation.spec.ts b/packages/nimble-components/src/button/tests/button.foundation.spec.ts new file mode 100644 index 0000000000..ff902d932f --- /dev/null +++ b/packages/nimble-components/src/button/tests/button.foundation.spec.ts @@ -0,0 +1,669 @@ +/** + * Based on tests in FAST repo: https://github.com/microsoft/fast/blob/archives/fast-element-1/packages/web-components/fast-foundation/src/button/button.spec.ts + */ +import { DOM } from '@microsoft/fast-element'; +import { eventClick } from '@microsoft/fast-web-utilities'; +import { fixture } from '../../utilities/tests/fixture'; +import { Button } from '..'; +import { template } from '../template'; + +const button = Button.compose({ + baseName: 'button', + template +}); + +async function setup(): Promise<{ + element: Button, + connect: () => Promise, + disconnect: () => Promise, + parent: HTMLElement +}> { + const { connect, disconnect, element, parent } = await fixture(button()); + + return { connect, disconnect, element, parent }; +} + +describe('Button', () => { + it('should set the `autofocus` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + + element.autofocus = true; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.hasAttribute('autofocus') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `disabled` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + + element.disabled = true; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.hasAttribute('disabled') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `form` attribute on the internal button when `formId` is provided', async () => { + const { element, connect, disconnect } = await setup(); + const formId = 'testId'; + + element.formId = 'testId'; + + await connect(); + + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('form') + ).toEqual(formId); + + await disconnect(); + }); + + it('should set the `formaction` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const formaction = 'test_action.asp'; + + element.formaction = formaction; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('formaction') + ).toEqual(formaction); + + await disconnect(); + }); + + it('should set the `formenctype` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const formenctype = 'text/plain'; + + element.formenctype = formenctype; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('formenctype') + ).toEqual(formenctype); + + await disconnect(); + }); + + it('should set the `formmethod` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const formmethod = 'post'; + + element.formmethod = formmethod; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('formmethod') + ).toEqual(formmethod); + + await disconnect(); + }); + + it('should set the `formnovalidate` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const formnovalidate = true; + + element.formnovalidate = formnovalidate; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('formnovalidate') + ).toEqual(formnovalidate.toString()); + + await disconnect(); + }); + + it('should set the `formtarget` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const formtarget = '_blank'; + + element.formtarget = formtarget; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('formtarget') + ).toEqual(formtarget); + + await disconnect(); + }); + + it('should set the `name` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const name = 'testName'; + + element.name = name; + + await connect(); + + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('name') + ).toEqual(name); + + await disconnect(); + }); + + it('should set the `type` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const type = 'submit'; + + element.type = type; + + await connect(); + + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('type') + ).toEqual(type); + + await disconnect(); + }); + + it('should set the `value` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const value = 'Reset'; + + element.value = value; + + await connect(); + + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('value') + ).toEqual(value); + + await disconnect(); + }); + + describe('Delegates ARIA button', () => { + it('should set the `aria-atomic` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaAtomic = 'true'; + + element.ariaAtomic = ariaAtomic; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-atomic') + ).toEqual(ariaAtomic); + + await disconnect(); + }); + + it('should set the `aria-busy` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaBusy = 'false'; + + element.ariaBusy = ariaBusy; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-busy') + ).toEqual(ariaBusy); + + await disconnect(); + }); + + it('should set the `aria-controls` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaControls = 'testId'; + + element.ariaControls = ariaControls; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-controls') + ).toEqual(ariaControls); + + await disconnect(); + }); + + it('should set the `aria-current` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaCurrent = 'page'; + + element.ariaCurrent = ariaCurrent; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-current') + ).toEqual(ariaCurrent); + + await disconnect(); + }); + + it('should set the `aria-describedby` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaDescribedby = 'testId'; + + element.ariaDescribedby = ariaDescribedby; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-describedby') + ).toEqual(ariaDescribedby); + + await disconnect(); + }); + + it('should set the `aria-details` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaDetails = 'testId'; + + element.ariaDetails = ariaDetails; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-details') + ).toEqual(ariaDetails); + + await disconnect(); + }); + + it('should set the `aria-disabled` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaDisabled = 'true'; + + element.ariaDisabled = ariaDisabled; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-disabled') + ).toEqual(ariaDisabled); + + await disconnect(); + }); + + it('should set the `aria-errormessage` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaErrormessage = 'test'; + + element.ariaErrormessage = ariaErrormessage; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-errormessage') + ).toEqual(ariaErrormessage); + + await disconnect(); + }); + + it('should set the `aria-expanded` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaExpanded = 'true'; + + element.ariaExpanded = ariaExpanded; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-expanded') + ).toEqual(ariaExpanded); + + await disconnect(); + }); + + it('should set the `aria-flowto` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaFlowto = 'testId'; + + element.ariaFlowto = ariaFlowto; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-flowto') + ).toEqual(ariaFlowto); + + await disconnect(); + }); + + it('should set the `aria-haspopup` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaHaspopup = 'true'; + + element.ariaHaspopup = ariaHaspopup; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-haspopup') + ).toEqual(ariaHaspopup); + + await disconnect(); + }); + + it('should set the `aria-hidden` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaHidden = 'true'; + + element.ariaHidden = ariaHidden; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-hidden') + ).toEqual(ariaHidden); + + await disconnect(); + }); + + it('should set the `aria-invalid` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaInvalid = 'spelling'; + + element.ariaInvalid = ariaInvalid; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-invalid') + ).toEqual(ariaInvalid); + + await disconnect(); + }); + + it('should set the `aria-keyshortcuts` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaKeyshortcuts = 'F4'; + + element.ariaKeyshortcuts = ariaKeyshortcuts; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-keyshortcuts') + ).toEqual(ariaKeyshortcuts); + + await disconnect(); + }); + + it('should set the `aria-label` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaLabel = 'Foo label'; + + element.ariaLabel = ariaLabel; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-label') + ).toEqual(ariaLabel); + + await disconnect(); + }); + + it('should set the `aria-labelledby` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaLabelledby = 'testId'; + + element.ariaLabelledby = ariaLabelledby; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-labelledby') + ).toEqual(ariaLabelledby); + + await disconnect(); + }); + + it('should set the `aria-live` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaLive = 'polite'; + + element.ariaLive = ariaLive; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-live') + ).toEqual(ariaLive); + + await disconnect(); + }); + + it('should set the `aria-owns` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaOwns = 'testId'; + + element.ariaOwns = ariaOwns; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-owns') + ).toEqual(ariaOwns); + + await disconnect(); + }); + + it('should set the `aria-pressed` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaPressed = 'true'; + + element.ariaPressed = ariaPressed; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-pressed') + ).toEqual(ariaPressed); + + await disconnect(); + }); + + it('should set the `aria-relevant` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaRelevant = 'removals'; + + element.ariaRelevant = ariaRelevant; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-relevant') + ).toEqual(ariaRelevant); + + await disconnect(); + }); + + it('should set the `aria-roledescription` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaRoledescription = 'slide'; + + element.ariaRoledescription = ariaRoledescription; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-roledescription') + ).toEqual(ariaRoledescription); + + await disconnect(); + }); + }); + + describe("of type 'submit'", () => { + it('should submit the parent form when clicked', async () => { + const { connect, disconnect, element, parent } = await setup(); + const form = document.createElement('form'); + element.setAttribute('type', 'submit'); + form.appendChild(element); + parent.appendChild(form); + + await connect(); + + const wasSumbitted = await new Promise(resolve => { + // Resolve as true when the event listener is handled + form.addEventListener('submit', (event: SubmitEvent) => { + event.preventDefault(); + expect(event.submitter).toEqual(element.proxy); + resolve(true); + }); + + element.click(); + + // Resolve false on the next update in case reset hasn't happened + DOM.queueUpdate(() => resolve(false)); + }); + + expect(wasSumbitted).toBeTrue(); + + await disconnect(); + }); + }); + + describe("of type 'reset'", () => { + it('should reset the parent form when clicked', async () => { + const { connect, disconnect, element, parent } = await setup(); + const form = document.createElement('form'); + element.setAttribute('type', 'reset'); + form.appendChild(element); + parent.appendChild(form); + + await connect(); + + const wasReset = await new Promise(resolve => { + // Resolve true when the event listener is handled + form.addEventListener('reset', () => resolve(true)); + + element.click(); + + // Resolve false on the next update in case reset hasn't happened + DOM.queueUpdate(() => resolve(false)); + }); + + expect(wasReset).toBeTrue(); + + await disconnect(); + }); + }); + + describe("of 'disabled'", () => { + it('should not propagate when clicked', async () => { + const { connect, disconnect, element, parent } = await setup(); + + element.disabled = true; + parent.appendChild(element); + + let wasClicked = false; + await connect(); + + parent.addEventListener(eventClick, () => { + wasClicked = true; + }); + + await DOM.nextUpdate(); + element.click(); + + expect(wasClicked).toEqual(false); + + await disconnect(); + }); + + it('should not propagate when spans within shadowRoot are clicked', async () => { + const { connect, disconnect, element, parent } = await setup(); + + element.disabled = true; + parent.appendChild(element); + + let wasClicked = false; + + await connect(); + + parent.addEventListener(eventClick, () => { + wasClicked = true; + }); + + await DOM.nextUpdate(); + + const elements = element.shadowRoot?.querySelectorAll('span'); + if (elements) { + const spans: HTMLSpanElement[] = Array.from(elements); + spans.forEach((span: HTMLSpanElement) => { + span.click(); + expect(wasClicked).toEqual(false); + }); + } + + await disconnect(); + }); + }); +}); diff --git a/packages/nimble-components/src/button/tests/button.spec.ts b/packages/nimble-components/src/button/tests/button.spec.ts index be7e1ac2a9..63efaa0423 100644 --- a/packages/nimble-components/src/button/tests/button.spec.ts +++ b/packages/nimble-components/src/button/tests/button.spec.ts @@ -1,6 +1,15 @@ +import { html } from '@microsoft/fast-element'; import { Button, buttonTag } from '..'; +import { fixture, type Fixture } from '../../utilities/tests/fixture'; +import { waitForUpdatesAsync } from '../../testing/async-helpers'; describe('Button', () => { + async function setup(tabIndex?: string): Promise> { + return fixture