From 1e982c15c7d5fd363cbefb42f3e0c2e43ff2d24f Mon Sep 17 00:00:00 2001 From: TaylorJ76 Date: Thu, 17 Oct 2024 09:18:51 +0100 Subject: [PATCH 01/14] chore: wip --- .../lib/breadcrumb-item/breadcrumb-item.ts | 2 +- libs/components/src/lib/fab/definition.ts | 2 +- libs/components/src/lib/fab/fab.ts | 7 +- .../{patterns => foundation/anchor}/anchor.ts | 4 +- .../src/shared/foundation/button/REAME.md | 151 ++ .../button/button.form-associated.ts | 15 + .../foundation/button/button.template.ts | 58 + .../src/shared/foundation/button/button.ts | 315 +++ .../src/shared/foundation/button/index.ts | 2 + .../design-system/component-presentation.ts | 131 + .../design-system/registration-context.ts | 128 + .../components/src/shared/foundation/di/di.ts | 2127 +++++++++++++++++ .../form-associated/form-associated.ts | 827 +++++++ .../foundation-element/foundation-element.ts | 292 +++ .../src/shared/foundation/interfaces.ts | 5 + .../{ => foundation}/patterns/aria-global.ts | 0 .../src/shared/foundation/patterns/index.ts | 2 + .../shared/foundation/patterns/start-end.ts | 140 ++ .../foundation/utilities/apply-mixins.ts | 26 + 19 files changed, 4225 insertions(+), 9 deletions(-) rename libs/components/src/shared/{patterns => foundation/anchor}/anchor.ts (95%) create mode 100644 libs/components/src/shared/foundation/button/REAME.md create mode 100644 libs/components/src/shared/foundation/button/button.form-associated.ts create mode 100644 libs/components/src/shared/foundation/button/button.template.ts create mode 100644 libs/components/src/shared/foundation/button/button.ts create mode 100644 libs/components/src/shared/foundation/button/index.ts create mode 100644 libs/components/src/shared/foundation/design-system/component-presentation.ts create mode 100644 libs/components/src/shared/foundation/design-system/registration-context.ts create mode 100644 libs/components/src/shared/foundation/di/di.ts create mode 100644 libs/components/src/shared/foundation/form-associated/form-associated.ts create mode 100644 libs/components/src/shared/foundation/foundation-element/foundation-element.ts create mode 100644 libs/components/src/shared/foundation/interfaces.ts rename libs/components/src/shared/{ => foundation}/patterns/aria-global.ts (100%) create mode 100644 libs/components/src/shared/foundation/patterns/index.ts create mode 100644 libs/components/src/shared/foundation/patterns/start-end.ts create mode 100644 libs/components/src/shared/foundation/utilities/apply-mixins.ts diff --git a/libs/components/src/lib/breadcrumb-item/breadcrumb-item.ts b/libs/components/src/lib/breadcrumb-item/breadcrumb-item.ts index 0fb3097ac7..a779e532e0 100644 --- a/libs/components/src/lib/breadcrumb-item/breadcrumb-item.ts +++ b/libs/components/src/lib/breadcrumb-item/breadcrumb-item.ts @@ -1,6 +1,6 @@ import { attr, observable } from '@microsoft/fast-element'; import { applyMixins, FoundationElement } from '@microsoft/fast-foundation'; -import { Anchor } from '../../shared/patterns/anchor'; +import { Anchor } from '../../shared/foundation/anchor/anchor'; /** * @public diff --git a/libs/components/src/lib/fab/definition.ts b/libs/components/src/lib/fab/definition.ts index 20e2b990c1..e3b26e53ab 100644 --- a/libs/components/src/lib/fab/definition.ts +++ b/libs/components/src/lib/fab/definition.ts @@ -1,4 +1,4 @@ -import type { FoundationElementDefinition } from '@microsoft/fast-foundation'; +import type { FoundationElementDefinition } from '../../shared/foundation/foundation-element/foundation-element'; import { registerFactory } from '../../shared/design-system'; import { iconRegistries } from '../icon/definition'; import styles from './fab.scss?inline'; diff --git a/libs/components/src/lib/fab/fab.ts b/libs/components/src/lib/fab/fab.ts index 9be09b5d0a..9299a7454b 100644 --- a/libs/components/src/lib/fab/fab.ts +++ b/libs/components/src/lib/fab/fab.ts @@ -1,9 +1,6 @@ -import { - applyMixins, - Button as FoundationButton, -} from '@microsoft/fast-foundation'; import { attr } from '@microsoft/fast-element'; - +import { applyMixins } from '../../shared/foundation/utilities/apply-mixins'; +import { Button as FoundationButton } from '../../shared/foundation/button'; import type { Connotation, Size } from '../enums.js'; import { AffixIconWithTrailing } from '../../shared/patterns/affix'; diff --git a/libs/components/src/shared/patterns/anchor.ts b/libs/components/src/shared/foundation/anchor/anchor.ts similarity index 95% rename from libs/components/src/shared/patterns/anchor.ts rename to libs/components/src/shared/foundation/anchor/anchor.ts index 6f844eec7c..75a0a23dae 100644 --- a/libs/components/src/shared/patterns/anchor.ts +++ b/libs/components/src/shared/foundation/anchor/anchor.ts @@ -1,6 +1,6 @@ import { attr } from '@microsoft/fast-element'; -import { applyMixins } from '@microsoft/fast-foundation'; -import { ARIAGlobalStatesAndProperties } from './aria-global'; +import { applyMixins } from '../utilities/apply-mixins'; +import { ARIAGlobalStatesAndProperties } from '../patterns/aria-global'; /** * Based largely on the {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a | element }. diff --git a/libs/components/src/shared/foundation/button/REAME.md b/libs/components/src/shared/foundation/button/REAME.md new file mode 100644 index 0000000000..639bdc384d --- /dev/null +++ b/libs/components/src/shared/foundation/button/REAME.md @@ -0,0 +1,151 @@ +--- +id: button +title: fast-button +sidebar_label: button +custom_edit_url: https://github.com/microsoft/fast/edit/master/packages/web-components/fast-foundation/src/button/README.md +description: fast-button is a web component implementation of a button element. +--- + +As defined by the [W3C](https://w3c.github.io/aria-practices/#button): + +> A button is a widget that enables users to trigger an action or event, such as submitting a form, opening a dialog, canceling an action, or performing a delete operation. + +`fast-button` is a web component implementation of an [HTML button element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button). The `fast-components` button supports several visual appearances (accent, lightweight, neutral, outline, stealth). + +## Setup + +```ts +import { + provideFASTDesignSystem, + fastButton +} from "@microsoft/fast-components"; + +provideFASTDesignSystem() + .register( + fastButton() + ); +``` + +## Usage + +```html live +Submit +``` + +## Create your own design + +```ts +import { + Button, + buttonTemplate as template, +} from "@microsoft/fast-foundation"; +import { buttonStyles as styles } from "./my-button.styles"; + +export const myButton = Button.compose({ + baseName: "button", + template, + styles, + shadowOptions: { + delegatesFocus: true, + }, +}); +``` + +:::note +This component is built with the expectation that focus is delegated to the button element rendered into the shadow DOM. +::: + +## API + + + +### class: `Button` + +#### Superclass + +| Name | Module | Package | +| ---------------------- | ------------------------------------- | ------- | +| `FormAssociatedButton` | /src/button/button.form-associated.js | | + +#### Fields + +| Name | Privacy | Type | Default | Description | Inherited From | +| ----------------------- | ------- | -------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| `autofocus` | public | `boolean` | | Determines if the element should receive document focus on page load. | | +| `formId` | public | `string` | | The id of a form to associate the element to. | | +| `formaction` | public | `string` | | See [` +`; \ No newline at end of file diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts new file mode 100644 index 0000000000..18ca8702cf --- /dev/null +++ b/libs/components/src/shared/foundation/button/button.ts @@ -0,0 +1,315 @@ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { attr, observable } from "@microsoft/fast-element"; +import { ARIAGlobalStatesAndProperties, StartEnd } from "../patterns/index"; +import type { StartEndOptions } from "../patterns/index"; +import { applyMixins } from "../utilities/apply-mixins"; +import type { FoundationElementDefinition } from "../foundation-element/foundation-element"; +import { FormAssociatedButton } from "./button.form-associated"; + +/** + * Button configuration options + * @public + */ +export type ButtonOptions = FoundationElementDefinition & StartEndOptions; + +/** + * A Button Custom HTML Element. + * Based largely on the {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button | + class="control" + part="control" + ?autofocus="${(x) => x.autofocus}" + ?disabled="${(x) => x.disabled}" + form="${(x) => x.formId}" + formaction="${(x) => x.formaction}" + formenctype="${(x) => x.formenctype}" + formmethod="${(x) => x.formmethod}" + formnovalidate="${(x) => x.formnovalidate}" + formtarget="${(x) => x.formtarget}" + name="${(x) => x.name}" + type="${(x) => x.type}" + value="${(x) => x.value}" + aria-atomic="${(x) => x.ariaAtomic}" + aria-busy="${(x) => x.ariaBusy}" + aria-controls="${(x) => x.ariaControls}" + aria-current="${(x) => x.ariaCurrent}" + aria-describedby="${(x) => x.ariaDescribedby}" + aria-details="${(x) => x.ariaDetails}" + aria-disabled="${(x) => x.ariaDisabled}" + aria-errormessage="${(x) => x.ariaErrormessage}" + aria-expanded="${(x) => x.ariaExpanded}" + aria-flowto="${(x) => x.ariaFlowto}" + aria-haspopup="${(x) => x.ariaHaspopup}" + aria-hidden="${(x) => x.ariaHidden}" + aria-invalid="${(x) => x.ariaInvalid}" + aria-keyshortcuts="${(x) => x.ariaKeyshortcuts}" + aria-label="${(x) => x.ariaLabel}" + aria-labelledby="${(x) => x.ariaLabelledby}" + aria-live="${(x) => x.ariaLive}" + aria-owns="${(x) => x.ariaOwns}" + aria-pressed="${(x) => x.ariaPressed}" + aria-relevant="${(x) => x.ariaRelevant}" + aria-roledescription="${(x) => x.ariaRoledescription}" + ${ref('control')} + > + ${startSlotTemplate(context, definition)} + + + + ${endSlotTemplate(context, definition)} + `; \ No newline at end of file diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts index 18ca8702cf..84a35d927c 100644 --- a/libs/components/src/shared/foundation/button/button.ts +++ b/libs/components/src/shared/foundation/button/button.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ -import { attr, observable } from "@microsoft/fast-element"; -import { ARIAGlobalStatesAndProperties, StartEnd } from "../patterns/index"; -import type { StartEndOptions } from "../patterns/index"; -import { applyMixins } from "../utilities/apply-mixins"; -import type { FoundationElementDefinition } from "../foundation-element/foundation-element"; -import { FormAssociatedButton } from "./button.form-associated"; +import { attr, observable } from '@microsoft/fast-element'; +import { applyMixins } from '@microsoft/fast-foundation'; +import type { FoundationElementDefinition } from '@microsoft/fast-foundation'; +import { ARIAGlobalStatesAndProperties, StartEnd } from '../patterns/index'; +import type { StartEndOptions } from '../patterns/index'; +import { FormAssociatedButton } from './button.form-associated'; /** * Button configuration options @@ -24,249 +24,251 @@ export type ButtonOptions = FoundationElementDefinition & StartEndOptions; * * @public */ -export class Button extends FormAssociatedButton { - /** - * Determines if the element should receive document focus on page load. - * - * @public - * @remarks - * HTML Attribute: autofocus - */ - @attr({ mode: "boolean" }) - // @ts-expect-error Type is incorrectly non-optional - public autofocus: boolean; +export class FoundationButton extends FormAssociatedButton { + /** + * Determines if the element should receive document focus on page load. + * + * @public + * @remarks + * HTML Attribute: autofocus + */ + @attr({ mode: 'boolean' }) + // @ts-expect-error Type is incorrectly non-optional + public autofocus: boolean; - /** - * The id of a form to associate the element to. - * - * @public - * @remarks - * HTML Attribute: form - */ - @attr({ attribute: "form" }) - // @ts-expect-error Type is incorrectly non-optional - public formId: string; + /** + * The id of a form to associate the element to. + * + * @public + * @remarks + * HTML Attribute: form + */ + @attr({ attribute: 'form' }) + // @ts-expect-error Type is incorrectly non-optional + public formId: string; - /** - * See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button | -`; \ No newline at end of file +`; diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts index 84a35d927c..5625e66891 100644 --- a/libs/components/src/shared/foundation/button/button.ts +++ b/libs/components/src/shared/foundation/button/button.ts @@ -314,4 +314,4 @@ applyMixins(DelegatesARIAButton, ARIAGlobalStatesAndProperties); * @internal */ export interface FoundationButton extends StartEnd, DelegatesARIAButton {} -applyMixins(FoundationButton, StartEnd, DelegatesARIAButton); \ No newline at end of file +applyMixins(FoundationButton, StartEnd, DelegatesARIAButton); diff --git a/libs/components/src/shared/foundation/button/index.ts b/libs/components/src/shared/foundation/button/index.ts index e9b4b7a7a4..04cbc4010e 100644 --- a/libs/components/src/shared/foundation/button/index.ts +++ b/libs/components/src/shared/foundation/button/index.ts @@ -1,2 +1,2 @@ -export * from "./button.template.js"; -export * from "./button.js"; \ No newline at end of file +export * from './button.template.js'; +export * from './button.js'; diff --git a/libs/components/src/shared/foundation/patterns/index.ts b/libs/components/src/shared/foundation/patterns/index.ts index 2fbd53896d..c1980f2f69 100644 --- a/libs/components/src/shared/foundation/patterns/index.ts +++ b/libs/components/src/shared/foundation/patterns/index.ts @@ -1,2 +1,2 @@ -export * from "./aria-global.js"; -export * from "./start-end.js"; \ No newline at end of file +export * from './aria-global.js'; +export * from './start-end.js'; diff --git a/libs/components/src/shared/foundation/patterns/start-end.ts b/libs/components/src/shared/foundation/patterns/start-end.ts index 53b6a83c00..89047e8344 100644 --- a/libs/components/src/shared/foundation/patterns/start-end.ts +++ b/libs/components/src/shared/foundation/patterns/start-end.ts @@ -32,7 +32,7 @@ export type StartEndOptions = StartOptions & EndOptions; * @public */ export class StartEnd { - /* eslint-disable @typescript-eslint/explicit-member-accessibility */ + /* eslint-disable @typescript-eslint/explicit-member-accessibility */ // @ts-expect-error Type is incorrectly non-optional public start: HTMLSlotElement; // @ts-expect-error Type is incorrectly non-optional @@ -63,7 +63,7 @@ export class StartEnd { * @public */ export const endSlotTemplate: ( - context: ElementDefinitionContext, + context: ElementDefinitionContext, definition: EndOptions ) => ViewTemplate = ( // @ts-expect-error Type is incorrectly non-optional @@ -122,13 +122,13 @@ export const startSlotTemplate: ( * @deprecated - use endSlotTemplate */ export const endTemplate: ViewTemplate = html` - - - + + + `; /** @@ -139,11 +139,11 @@ export const endTemplate: ViewTemplate = html` * @deprecated - use startSlotTemplate */ export const startTemplate: ViewTemplate = html` - - - -`; \ No newline at end of file + + + +`; From fb66db49df1485752a9fd755851ba6fdc9f324f1 Mon Sep 17 00:00:00 2001 From: TaylorJ76 Date: Tue, 22 Oct 2024 08:20:28 +0100 Subject: [PATCH 04/14] chore: remove unused ts ignores --- libs/components/src/shared/foundation/button/button.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts index 5625e66891..28a87fbddb 100644 --- a/libs/components/src/shared/foundation/button/button.ts +++ b/libs/components/src/shared/foundation/button/button.ts @@ -57,7 +57,6 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formaction: string; - // @ts-expect-error Type is incorrectly non-optional private formactionChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formAction = this.formaction; @@ -74,7 +73,6 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formenctype: string; - // @ts-expect-error Type is incorrectly non-optional private formenctypeChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formEnctype = this.formenctype; @@ -91,7 +89,6 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formmethod: string; - // @ts-expect-error Type is incorrectly non-optional private formmethodChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formMethod = this.formmethod; @@ -108,7 +105,6 @@ export class FoundationButton extends FormAssociatedButton { @attr({ mode: 'boolean' }) // @ts-expect-error Type is incorrectly non-optional public formnovalidate: boolean; - // @ts-expect-error Type is incorrectly non-optional private formnovalidateChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formNoValidate = this.formnovalidate; @@ -125,7 +121,6 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formtarget: '_self' | '_blank' | '_parent' | '_top'; - // @ts-expect-error Type is incorrectly non-optional private formtargetChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formTarget = this.formtarget; @@ -142,7 +137,6 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public type: 'submit' | 'reset' | 'button'; - // @ts-expect-error Type is incorrectly non-optional private typeChanged( previous: 'submit' | 'reset' | 'button' | void, next: 'submit' | 'reset' | 'button' From d1440fa89c7547f39289bacd578bba0608ceb69b Mon Sep 17 00:00:00 2001 From: TaylorJ76 Date: Tue, 22 Oct 2024 08:28:11 +0100 Subject: [PATCH 05/14] chore: remove unused ts ignores --- libs/components/src/shared/foundation/patterns/start-end.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/components/src/shared/foundation/patterns/start-end.ts b/libs/components/src/shared/foundation/patterns/start-end.ts index 89047e8344..ed9879aea3 100644 --- a/libs/components/src/shared/foundation/patterns/start-end.ts +++ b/libs/components/src/shared/foundation/patterns/start-end.ts @@ -66,7 +66,6 @@ export const endSlotTemplate: ( context: ElementDefinitionContext, definition: EndOptions ) => ViewTemplate = ( - // @ts-expect-error Type is incorrectly non-optional context: ElementDefinitionContext, definition: EndOptions ) => html` @@ -95,7 +94,6 @@ export const startSlotTemplate: ( context: ElementDefinitionContext, definition: StartOptions ) => ViewTemplate = ( - // @ts-expect-error Type is incorrectly non-optional context: ElementDefinitionContext, definition: StartOptions ) => html` From fd5bf6a950fe88a0bdaca74e38bf52505d4e4974 Mon Sep 17 00:00:00 2001 From: TaylorJ76 Date: Tue, 22 Oct 2024 09:50:53 +0100 Subject: [PATCH 06/14] chore: surpress ts errors --- libs/components/src/shared/foundation/button/button.ts | 6 ++++++ libs/components/src/shared/foundation/patterns/start-end.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts index 28a87fbddb..c521de5504 100644 --- a/libs/components/src/shared/foundation/button/button.ts +++ b/libs/components/src/shared/foundation/button/button.ts @@ -57,6 +57,7 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formaction: string; + // @ts-expect-error Function is delcared but not used private formactionChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formAction = this.formaction; @@ -73,6 +74,7 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formenctype: string; + // @ts-expect-error Function is delcared but not used private formenctypeChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formEnctype = this.formenctype; @@ -89,6 +91,7 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formmethod: string; + // @ts-expect-error Function is delcared but not used private formmethodChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formMethod = this.formmethod; @@ -105,6 +108,7 @@ export class FoundationButton extends FormAssociatedButton { @attr({ mode: 'boolean' }) // @ts-expect-error Type is incorrectly non-optional public formnovalidate: boolean; + // @ts-expect-error Function is delcared but not used private formnovalidateChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formNoValidate = this.formnovalidate; @@ -121,6 +125,7 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public formtarget: '_self' | '_blank' | '_parent' | '_top'; + // @ts-expect-error Function is delcared but not used private formtargetChanged(): void { if (this.proxy instanceof HTMLInputElement) { this.proxy.formTarget = this.formtarget; @@ -137,6 +142,7 @@ export class FoundationButton extends FormAssociatedButton { @attr // @ts-expect-error Type is incorrectly non-optional public type: 'submit' | 'reset' | 'button'; + // @ts-expect-error Function is delcared but not used private typeChanged( previous: 'submit' | 'reset' | 'button' | void, next: 'submit' | 'reset' | 'button' diff --git a/libs/components/src/shared/foundation/patterns/start-end.ts b/libs/components/src/shared/foundation/patterns/start-end.ts index ed9879aea3..45d9a81054 100644 --- a/libs/components/src/shared/foundation/patterns/start-end.ts +++ b/libs/components/src/shared/foundation/patterns/start-end.ts @@ -66,7 +66,7 @@ export const endSlotTemplate: ( context: ElementDefinitionContext, definition: EndOptions ) => ViewTemplate = ( - context: ElementDefinitionContext, + _context: ElementDefinitionContext, definition: EndOptions ) => html` ViewTemplate = ( - context: ElementDefinitionContext, + _context: ElementDefinitionContext, definition: StartOptions ) => html` Date: Wed, 23 Oct 2024 11:13:49 +0100 Subject: [PATCH 07/14] Turn on noUnusedLocals --- apps/docs/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 6d7945c687..c3547825e0 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -6,6 +6,7 @@ "strict": true, "noImplicitOverride": true, "noImplicitReturns": true, + "noUnusedLocals": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "resolveJsonModule": true From 15255b84c985889978e574bcb2a20fb188fc5cea Mon Sep 17 00:00:00 2001 From: TaylorJ76 Date: Tue, 29 Oct 2024 09:00:20 +0000 Subject: [PATCH 08/14] chore: adds tests to foundation button --- .../shared/foundation/button/button.spec.ts | 563 ++++++++++++++++++ .../foundation/test-utilities/fixture.spec.ts | 81 +++ .../foundation/test-utilities/fixture.ts | 216 +++++++ libs/components/tsconfig.spec.json | 2 +- 4 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 libs/components/src/shared/foundation/button/button.spec.ts create mode 100644 libs/components/src/shared/foundation/test-utilities/fixture.spec.ts create mode 100644 libs/components/src/shared/foundation/test-utilities/fixture.ts diff --git a/libs/components/src/shared/foundation/button/button.spec.ts b/libs/components/src/shared/foundation/button/button.spec.ts new file mode 100644 index 0000000000..0cdbe1c9a1 --- /dev/null +++ b/libs/components/src/shared/foundation/button/button.spec.ts @@ -0,0 +1,563 @@ +import { DOM } from "@microsoft/fast-element"; +import { eventClick } from "@microsoft/fast-web-utilities"; +import { fixture } from '../test-utilities/fixture'; +import { FoundationButton as Button, buttonTemplate as template } from "./index"; + +const FASTButton = Button.compose({ + baseName: "button", + template +}) + +async function setup() { + const { connect, disconnect, element, parent } = await fixture(FASTButton()); + + return { connect, disconnect, element, parent }; +} + +describe("Foundation 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") + ).toEqual(true); + + 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") + ).toEqual(true); + + 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 '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).toEqual(true); + + await disconnect(); + }); + }); + + describe("of 'disabled'", () => { + 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(); + }); + }); +}); \ No newline at end of file diff --git a/libs/components/src/shared/foundation/test-utilities/fixture.spec.ts b/libs/components/src/shared/foundation/test-utilities/fixture.spec.ts new file mode 100644 index 0000000000..a82b3a557f --- /dev/null +++ b/libs/components/src/shared/foundation/test-utilities/fixture.spec.ts @@ -0,0 +1,81 @@ +import { + attr, + customElement, + DOM, + FASTElement, + html, + observable, +} from "@microsoft/fast-element"; +import { fixture, uniqueElementName } from "./fixture"; + +describe("The fixture helper", () => { + const name = uniqueElementName(); + const template = html` + ${x => x.value} + + `; + + @customElement({ + name, + template, + }) + class MyElement extends FASTElement { + /* eslint-disable-next-line */ + @attr value = "value"; + } + + class MyModel { + @observable value = "different value"; + } + + it("can create a fixture for an element by name", async () => { + const { element } = await fixture(name); + expect(element instanceof MyElement).toEqual(true); + }); + + it("can connect an element", async () => { + const { element, connect } = await fixture(name); + + expect(element.isConnected).toEqual(false); + + await connect(); + + expect(element.isConnected).toEqual(true); + + document.body.removeChild(element.parentElement!); + }); + + it("can disconnect an element", async () => { + const { element, connect, disconnect } = await fixture(name); + + expect(element.isConnected).toEqual(false); + + await connect(); + + expect(element.isConnected).toEqual(true); + + await disconnect(); + + expect(element.isConnected).toEqual(false); + }); + + it("can bind an element to data", async () => { + const source = new MyModel(); + const { element, disconnect } = await fixture( + html` + <${name} value=${x => x.value}> + `, + { source } + ); + + expect(element.value).toEqual(source.value); + + source.value = "something else"; + + await DOM.nextUpdate(); + + expect(element.value).toEqual(source.value); + + await disconnect(); + }); +}); \ No newline at end of file diff --git a/libs/components/src/shared/foundation/test-utilities/fixture.ts b/libs/components/src/shared/foundation/test-utilities/fixture.ts new file mode 100644 index 0000000000..df95d69729 --- /dev/null +++ b/libs/components/src/shared/foundation/test-utilities/fixture.ts @@ -0,0 +1,216 @@ +import { + defaultExecutionContext, + ExecutionContext, + HTMLView, + ViewTemplate, +} from "@microsoft/fast-element"; +import type { Constructable } from "@microsoft/fast-element"; +import { DesignSystem } from "@microsoft/fast-foundation"; +import type { + Container, + DesignSystemRegistrationContext, + FoundationElementDefinition, + FoundationElementRegistry +} from "@microsoft/fast-foundation"; + +/** +* Options used to customize the creation of the test fixture. +*/ +export interface FixtureOptions { + /** + * The document to run the fixture in. + * @defaultValue `globalThis.document` + */ + document?: Document; + + /** + * The parent element to append the fixture to. + * @defaultValue An instance of `HTMLDivElement`. + */ + parent?: HTMLElement; + + /** + * The data source to bind the HTML to. + * @defaultValue An empty object. + */ + source?: any; + + /** + * The execution context to use during binding. + * @defaultValue {@link @microsoft/fast-element#defaultExecutionContext} + */ + context?: ExecutionContext; + + /** + * A pre-configured design system instance used in setting up the fixture. + */ + designSystem?: DesignSystem; +} + +export interface Fixture { + /** + * The document the fixture is running in. + */ + document: Document; + + /** + * The template the fixture was created from. + */ + template: ViewTemplate; + + /** + * The view that was created from the fixture's template. + */ + view: HTMLView; + + /** + * The parent element that the view was appended to. + * @remarks + * This element will be appended to the DOM only + * after {@link Fixture.connect} has been called. + */ + parent: HTMLElement; + + /** + * The first element in the {@link Fixture.view}. + */ + element: TElement; + + /** + * Adds the {@link Fixture.parent} to the DOM, causing the + * connect lifecycle to begin. + * @remarks + * Yields control to the caller one Microtask later, in order to + * ensure that the DOM has settled. + */ + connect: () => Promise; + + /** + * Removes the {@link Fixture.parent} from the DOM, causing the + * disconnect lifecycle to begin. + * @remarks + * Yields control to the caller one Microtask later, in order to + * ensure that the DOM has settled. + */ + disconnect: () => Promise; +} + +function findElement(view: HTMLView): HTMLElement { + let current: Node | null = view.firstChild; + + while (current !== null && current.nodeType !== 1) { + current = current.nextSibling; + } + + return current as any; +} + +/** +* Creates a random, unique name suitable for use as a Custom Element name. +*/ +export function uniqueElementName(): string { + return `fast-unique-${Math.random().toString(36).substring(7)}`; +} + +/* eslint-disable @typescript-eslint/no-unused-vars */ +// @ts-expect-error variable is never read +function isElementRegistry( + obj: any +): obj is FoundationElementRegistry { + return typeof obj.register === "function"; +} + +/** +* Creates a test fixture suitable for testing custom elements, templates, and bindings. +* @param templateNameOrRegistry An HTML template or single element name to create the fixture for. +* @param options Enables customizing fixture creation behavior. +* @remarks +* Yields control to the caller one Microtask later, in order to +* ensure that the DOM has settled. +*/ +export async function fixture( + templateNameOrRegistry: + | ViewTemplate + | string + | FoundationElementRegistry> + | [ + FoundationElementRegistry< + FoundationElementDefinition, + Constructable + >, + ...FoundationElementRegistry[] + ], + options: FixtureOptions = {} +): Promise> { + const document = options.document || globalThis.document; + const parent = options.parent || document.createElement("div"); + const source = options.source || {}; + const context = options.context || defaultExecutionContext; + + if (typeof templateNameOrRegistry === "string") { + const html = `<${templateNameOrRegistry}>`; + templateNameOrRegistry = new ViewTemplate(html, []); + } else if (isElementRegistry(templateNameOrRegistry)) { + templateNameOrRegistry = [templateNameOrRegistry]; + } + + if (Array.isArray(templateNameOrRegistry)) { + const first = templateNameOrRegistry[0]; + const ds = options.designSystem || DesignSystem.getOrCreate(parent); + let prefix = ""; + + ds.register(templateNameOrRegistry, { + // @ts-expect-error variable is never read + register(container: Container, context: DesignSystemRegistrationContext) { + prefix = context.elementPrefix; + }, + }); + + const elementName = `${prefix}-${first.definition.baseName}`; + const html = `<${elementName}>`; + templateNameOrRegistry = new ViewTemplate(html, []); + } + + const view = templateNameOrRegistry.create(); + const element = findElement(view) as any; + let isConnected = false; + + view.bind(source, context); + view.appendTo(parent); + + customElements.upgrade(parent); + + // Hook into the Microtask Queue to ensure the DOM is settled + // before yielding control to the caller. + await Promise.resolve(); + + const connect = async () => { + if (isConnected) { + return; + } + + isConnected = true; + document.body.appendChild(parent); + await Promise.resolve(); + }; + + const disconnect = async () => { + if (!isConnected) { + return; + } + + isConnected = false; + document.body.removeChild(parent); + await Promise.resolve(); + }; + + return { + document, + template: templateNameOrRegistry, + view, + parent, + element, + connect, + disconnect, + }; +} \ No newline at end of file diff --git a/libs/components/tsconfig.spec.json b/libs/components/tsconfig.spec.json index 78272f62a3..2d02ba2f73 100644 --- a/libs/components/tsconfig.spec.json +++ b/libs/components/tsconfig.spec.json @@ -10,5 +10,5 @@ "target": "ES6" }, "exclude": ["**/*.config.spec.ts"], - "include": ["**/*.spec.ts", "**/*.d.ts"] + "include": ["**/*.spec.ts", "**/*.d.ts", "**/fixture.ts"] } From 16cb939a04497cdc4b6e983dde98a2bb692631ba Mon Sep 17 00:00:00 2001 From: TaylorJ76 Date: Tue, 29 Oct 2024 09:18:11 +0000 Subject: [PATCH 09/14] chore: fomatting --- .../shared/foundation/button/button.spec.ts | 785 +++++++++--------- .../foundation/test-utilities/fixture.spec.ts | 116 +-- .../foundation/test-utilities/fixture.ts | 384 ++++----- 3 files changed, 657 insertions(+), 628 deletions(-) diff --git a/libs/components/src/shared/foundation/button/button.spec.ts b/libs/components/src/shared/foundation/button/button.spec.ts index 0cdbe1c9a1..76807b3134 100644 --- a/libs/components/src/shared/foundation/button/button.spec.ts +++ b/libs/components/src/shared/foundation/button/button.spec.ts @@ -1,563 +1,586 @@ -import { DOM } from "@microsoft/fast-element"; -import { eventClick } from "@microsoft/fast-web-utilities"; +import { DOM } from '@microsoft/fast-element'; +import { eventClick } from '@microsoft/fast-web-utilities'; import { fixture } from '../test-utilities/fixture'; -import { FoundationButton as Button, buttonTemplate as template } from "./index"; +import { + FoundationButton as Button, + buttonTemplate as template, +} from './index'; const FASTButton = Button.compose({ - baseName: "button", - template -}) + baseName: 'button', + template, +}); async function setup() { - const { connect, disconnect, element, parent } = await fixture(FASTButton()); + const { connect, disconnect, element, parent } = await fixture(FASTButton()); - return { connect, disconnect, element, parent }; + return { connect, disconnect, element, parent }; } -describe("Foundation Button", () => { - it("should set the `autofocus` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); +describe('Foundation Button', () => { + it('should set the `autofocus` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); - element.autofocus = true; + element.autofocus = true; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.hasAttribute("autofocus") - ).toEqual(true); + expect( + element.shadowRoot?.querySelector('button')?.hasAttribute('autofocus') + ).toEqual(true); - await disconnect(); - }); + await disconnect(); + }); - it("should set the `disabled` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); + it('should set the `disabled` attribute on the internal button when provided', async () => { + const { element, connect, disconnect } = await setup(); - element.disabled = true; + element.disabled = true; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.hasAttribute("disabled") - ).toEqual(true); + expect( + element.shadowRoot?.querySelector('button')?.hasAttribute('disabled') + ).toEqual(true); - await disconnect(); - }); + 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"; + 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"; + element.formId = 'testId'; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("form") - ).toEqual(formId); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('form') + ).toEqual(formId); - await disconnect(); - }); + 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"; + 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; + element.formaction = formaction; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("formaction") - ).toEqual(formaction); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('formaction') + ).toEqual(formaction); - await disconnect(); - }); + 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"; + 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; + element.formenctype = formenctype; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("formenctype") - ).toEqual(formenctype); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('formenctype') + ).toEqual(formenctype); - await disconnect(); - }); + await disconnect(); + }); - it("should set the `formmethod` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); - const formmethod = "post"; + 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; + element.formmethod = formmethod; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("formmethod") - ).toEqual(formmethod); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('formmethod') + ).toEqual(formmethod); - await disconnect(); - }); + await disconnect(); + }); - it("should set the `formnovalidate` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); - const formnovalidate = true; + 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; + element.formnovalidate = formnovalidate; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("formnovalidate") - ).toEqual(formnovalidate.toString()); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('formnovalidate') + ).toEqual(formnovalidate.toString()); - await disconnect(); - }); + await disconnect(); + }); - it("should set the `formtarget` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); - const formtarget = "_blank"; + 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; + element.formtarget = formtarget; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("formtarget") - ).toEqual(formtarget); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('formtarget') + ).toEqual(formtarget); - await disconnect(); - }); + await disconnect(); + }); - it("should set the `name` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); - const name = "testName"; + 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; + element.name = name; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("name") - ).toEqual(name); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('name') + ).toEqual(name); - await disconnect(); - }); + await disconnect(); + }); - it("should set the `type` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); - const type = "submit"; + 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; + element.type = type; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("type") - ).toEqual(type); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('type') + ).toEqual(type); - await disconnect(); - }); + await disconnect(); + }); - it("should set the `value` attribute on the internal button when provided", async () => { - const { element, connect, disconnect } = await setup(); - const value = "Reset"; + 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; + element.value = value; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("value") - ).toEqual(value); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('value') + ).toEqual(value); - await disconnect(); - }); + 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"; + 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; + element.ariaAtomic = ariaAtomic; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-atomic") - ).toEqual(ariaAtomic); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('aria-atomic') + ).toEqual(ariaAtomic); - await disconnect(); - }); + 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"; + 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; + element.ariaBusy = ariaBusy; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-busy") - ).toEqual(ariaBusy); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('aria-busy') + ).toEqual(ariaBusy); - await disconnect(); - }); + 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"; + 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; + element.ariaControls = ariaControls; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-controls") - ).toEqual(ariaControls); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-controls') + ).toEqual(ariaControls); - await disconnect(); - }); + 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"; + 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; + element.ariaCurrent = ariaCurrent; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-current") - ).toEqual(ariaCurrent); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-current') + ).toEqual(ariaCurrent); - await disconnect(); - }); + 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"; + 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; + element.ariaDescribedby = ariaDescribedby; - await connect(); + await connect(); - expect( - element.shadowRoot - ?.querySelector("button") - ?.getAttribute("aria-describedby") - ).toEqual(ariaDescribedby); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-describedby') + ).toEqual(ariaDescribedby); - await disconnect(); - }); + 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"; + 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; + element.ariaDetails = ariaDetails; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-details") - ).toEqual(ariaDetails); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-details') + ).toEqual(ariaDetails); - await disconnect(); - }); + 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"; + 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; + element.ariaDisabled = ariaDisabled; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-disabled") - ).toEqual(ariaDisabled); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-disabled') + ).toEqual(ariaDisabled); - await disconnect(); - }); + 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"; + 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; + element.ariaErrormessage = ariaErrormessage; - await connect(); + await connect(); - expect( - element.shadowRoot - ?.querySelector("button") - ?.getAttribute("aria-errormessage") - ).toEqual(ariaErrormessage); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-errormessage') + ).toEqual(ariaErrormessage); - await disconnect(); - }); + 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"; + 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; + element.ariaExpanded = ariaExpanded; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-expanded") - ).toEqual(ariaExpanded); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-expanded') + ).toEqual(ariaExpanded); - await disconnect(); - }); + 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"; + 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; + element.ariaFlowto = ariaFlowto; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-flowto") - ).toEqual(ariaFlowto); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('aria-flowto') + ).toEqual(ariaFlowto); - await disconnect(); - }); + 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"; + 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; + element.ariaHaspopup = ariaHaspopup; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-haspopup") - ).toEqual(ariaHaspopup); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-haspopup') + ).toEqual(ariaHaspopup); - await disconnect(); - }); + 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"; + 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; + element.ariaHidden = ariaHidden; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-hidden") - ).toEqual(ariaHidden); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('aria-hidden') + ).toEqual(ariaHidden); - await disconnect(); - }); + 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"; + 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; + element.ariaInvalid = ariaInvalid; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-invalid") - ).toEqual(ariaInvalid); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-invalid') + ).toEqual(ariaInvalid); - await disconnect(); - }); + 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"; + 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; + element.ariaKeyshortcuts = ariaKeyshortcuts; - await connect(); + await connect(); - expect( - element.shadowRoot - ?.querySelector("button") - ?.getAttribute("aria-keyshortcuts") - ).toEqual(ariaKeyshortcuts); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-keyshortcuts') + ).toEqual(ariaKeyshortcuts); - await disconnect(); - }); + 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"; + 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; + element.ariaLabel = ariaLabel; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-label") - ).toEqual(ariaLabel); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('aria-label') + ).toEqual(ariaLabel); - await disconnect(); - }); + 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"; + 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; + element.ariaLabelledby = ariaLabelledby; - await connect(); + await connect(); - expect( - element.shadowRoot - ?.querySelector("button") - ?.getAttribute("aria-labelledby") - ).toEqual(ariaLabelledby); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-labelledby') + ).toEqual(ariaLabelledby); - await disconnect(); - }); + 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"; + 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; + element.ariaLive = ariaLive; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-live") - ).toEqual(ariaLive); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('aria-live') + ).toEqual(ariaLive); - await disconnect(); - }); + 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"; + 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; + element.ariaOwns = ariaOwns; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-owns") - ).toEqual(ariaOwns); + expect( + element.shadowRoot?.querySelector('button')?.getAttribute('aria-owns') + ).toEqual(ariaOwns); - await disconnect(); - }); + 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"; + 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; + element.ariaPressed = ariaPressed; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-pressed") - ).toEqual(ariaPressed); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-pressed') + ).toEqual(ariaPressed); - await disconnect(); - }); + 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"; + 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; + element.ariaRelevant = ariaRelevant; - await connect(); + await connect(); - expect( - element.shadowRoot?.querySelector("button")?.getAttribute("aria-relevant") - ).toEqual(ariaRelevant); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-relevant') + ).toEqual(ariaRelevant); - await disconnect(); - }); + 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"; + 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; + element.ariaRoledescription = ariaRoledescription; - await connect(); + await connect(); - expect( - element.shadowRoot - ?.querySelector("button") - ?.getAttribute("aria-roledescription") - ).toEqual(ariaRoledescription); + expect( + element.shadowRoot + ?.querySelector('button') + ?.getAttribute('aria-roledescription') + ).toEqual(ariaRoledescription); - await disconnect(); - }); - }); + 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); + 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(); + await connect(); - const wasReset = await new Promise(resolve => { - // Resolve true when the event listener is handled - form.addEventListener("reset", () => resolve(true)); + const wasReset = await new Promise((resolve) => { + // Resolve true when the event listener is handled + form.addEventListener('reset', () => resolve(true)); - element.click(); + element.click(); - // Resolve false on the next update in case reset hasn't happened - DOM.queueUpdate(() => resolve(false)); - }); + // Resolve false on the next update in case reset hasn't happened + DOM.queueUpdate(() => resolve(false)); + }); - expect(wasReset).toEqual(true); + expect(wasReset).toEqual(true); - await disconnect(); - }); - }); + await disconnect(); + }); + }); - describe("of 'disabled'", () => { - it("should not propagate when spans within shadowRoot are clicked", async () => { - const { connect, disconnect, element, parent } = await setup(); + describe("of 'disabled'", () => { + it('should not propagate when spans within shadowRoot are clicked', async () => { + const { connect, disconnect, element, parent } = await setup(); - element.disabled = true; - parent.appendChild(element); + element.disabled = true; + parent.appendChild(element); - let wasClicked = false; + let wasClicked = false; - await connect(); + await connect(); - parent.addEventListener(eventClick, () => { - wasClicked = true; - }) + parent.addEventListener(eventClick, () => { + wasClicked = true; + }); - await DOM.nextUpdate(); + 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); - }) - } + 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(); - }); - }); -}); \ No newline at end of file + await disconnect(); + }); + }); +}); diff --git a/libs/components/src/shared/foundation/test-utilities/fixture.spec.ts b/libs/components/src/shared/foundation/test-utilities/fixture.spec.ts index a82b3a557f..f0f45d3c3e 100644 --- a/libs/components/src/shared/foundation/test-utilities/fixture.spec.ts +++ b/libs/components/src/shared/foundation/test-utilities/fixture.spec.ts @@ -1,81 +1,81 @@ import { - attr, - customElement, - DOM, - FASTElement, - html, - observable, -} from "@microsoft/fast-element"; -import { fixture, uniqueElementName } from "./fixture"; + attr, + customElement, + DOM, + FASTElement, + html, + observable, +} from '@microsoft/fast-element'; +import { fixture, uniqueElementName } from './fixture'; -describe("The fixture helper", () => { - const name = uniqueElementName(); - const template = html` - ${x => x.value} - - `; +describe('The fixture helper', () => { + const name = uniqueElementName(); + const template = html` + ${(x) => x.value} + + `; - @customElement({ - name, - template, - }) - class MyElement extends FASTElement { - /* eslint-disable-next-line */ - @attr value = "value"; - } + @customElement({ + name, + template, + }) + class MyElement extends FASTElement { + /* eslint-disable-next-line */ + @attr value = 'value'; + } - class MyModel { - @observable value = "different value"; - } + class MyModel { + @observable value = 'different value'; + } - it("can create a fixture for an element by name", async () => { - const { element } = await fixture(name); - expect(element instanceof MyElement).toEqual(true); - }); + it('can create a fixture for an element by name', async () => { + const { element } = await fixture(name); + expect(element instanceof MyElement).toEqual(true); + }); - it("can connect an element", async () => { - const { element, connect } = await fixture(name); + it('can connect an element', async () => { + const { element, connect } = await fixture(name); - expect(element.isConnected).toEqual(false); + expect(element.isConnected).toEqual(false); - await connect(); + await connect(); - expect(element.isConnected).toEqual(true); + expect(element.isConnected).toEqual(true); - document.body.removeChild(element.parentElement!); - }); + document.body.removeChild(element.parentElement!); + }); - it("can disconnect an element", async () => { - const { element, connect, disconnect } = await fixture(name); + it('can disconnect an element', async () => { + const { element, connect, disconnect } = await fixture(name); - expect(element.isConnected).toEqual(false); + expect(element.isConnected).toEqual(false); - await connect(); + await connect(); - expect(element.isConnected).toEqual(true); + expect(element.isConnected).toEqual(true); - await disconnect(); + await disconnect(); - expect(element.isConnected).toEqual(false); - }); + expect(element.isConnected).toEqual(false); + }); - it("can bind an element to data", async () => { - const source = new MyModel(); - const { element, disconnect } = await fixture( - html` - <${name} value=${x => x.value}> + it('can bind an element to data', async () => { + const source = new MyModel(); + const { element, disconnect } = await fixture( + html` + <${name} value=${(x) => x.value}> `, - { source } - ); + { source } + ); - expect(element.value).toEqual(source.value); + expect(element.value).toEqual(source.value); - source.value = "something else"; + source.value = 'something else'; - await DOM.nextUpdate(); + await DOM.nextUpdate(); - expect(element.value).toEqual(source.value); + expect(element.value).toEqual(source.value); - await disconnect(); - }); -}); \ No newline at end of file + await disconnect(); + }); +}); diff --git a/libs/components/src/shared/foundation/test-utilities/fixture.ts b/libs/components/src/shared/foundation/test-utilities/fixture.ts index df95d69729..8f5103334e 100644 --- a/libs/components/src/shared/foundation/test-utilities/fixture.ts +++ b/libs/components/src/shared/foundation/test-utilities/fixture.ts @@ -1,216 +1,222 @@ import { - defaultExecutionContext, - ExecutionContext, - HTMLView, - ViewTemplate, -} from "@microsoft/fast-element"; -import type { Constructable } from "@microsoft/fast-element"; -import { DesignSystem } from "@microsoft/fast-foundation"; -import type { - Container, - DesignSystemRegistrationContext, - FoundationElementDefinition, - FoundationElementRegistry -} from "@microsoft/fast-foundation"; + defaultExecutionContext, + ExecutionContext, + HTMLView, + ViewTemplate, +} from '@microsoft/fast-element'; +import type { Constructable } from '@microsoft/fast-element'; +import { DesignSystem } from '@microsoft/fast-foundation'; +import type { + Container, + DesignSystemRegistrationContext, + FoundationElementDefinition, + FoundationElementRegistry, +} from '@microsoft/fast-foundation'; /** -* Options used to customize the creation of the test fixture. -*/ + * Options used to customize the creation of the test fixture. + */ export interface FixtureOptions { - /** - * The document to run the fixture in. - * @defaultValue `globalThis.document` - */ - document?: Document; - - /** - * The parent element to append the fixture to. - * @defaultValue An instance of `HTMLDivElement`. - */ - parent?: HTMLElement; - - /** - * The data source to bind the HTML to. - * @defaultValue An empty object. - */ - source?: any; - - /** - * The execution context to use during binding. - * @defaultValue {@link @microsoft/fast-element#defaultExecutionContext} - */ - context?: ExecutionContext; - - /** - * A pre-configured design system instance used in setting up the fixture. - */ - designSystem?: DesignSystem; + /** + * The document to run the fixture in. + * @defaultValue `globalThis.document` + */ + document?: Document; + + /** + * The parent element to append the fixture to. + * @defaultValue An instance of `HTMLDivElement`. + */ + parent?: HTMLElement; + + /** + * The data source to bind the HTML to. + * @defaultValue An empty object. + */ + source?: any; + + /** + * The execution context to use during binding. + * @defaultValue {@link @microsoft/fast-element#defaultExecutionContext} + */ + context?: ExecutionContext; + + /** + * A pre-configured design system instance used in setting up the fixture. + */ + designSystem?: DesignSystem; } export interface Fixture { - /** - * The document the fixture is running in. - */ - document: Document; - - /** - * The template the fixture was created from. - */ - template: ViewTemplate; - - /** - * The view that was created from the fixture's template. - */ - view: HTMLView; - - /** - * The parent element that the view was appended to. - * @remarks - * This element will be appended to the DOM only - * after {@link Fixture.connect} has been called. - */ - parent: HTMLElement; - - /** - * The first element in the {@link Fixture.view}. - */ - element: TElement; - - /** - * Adds the {@link Fixture.parent} to the DOM, causing the - * connect lifecycle to begin. - * @remarks - * Yields control to the caller one Microtask later, in order to - * ensure that the DOM has settled. - */ - connect: () => Promise; - - /** - * Removes the {@link Fixture.parent} from the DOM, causing the - * disconnect lifecycle to begin. - * @remarks - * Yields control to the caller one Microtask later, in order to - * ensure that the DOM has settled. - */ - disconnect: () => Promise; + /** + * The document the fixture is running in. + */ + document: Document; + + /** + * The template the fixture was created from. + */ + template: ViewTemplate; + + /** + * The view that was created from the fixture's template. + */ + view: HTMLView; + + /** + * The parent element that the view was appended to. + * @remarks + * This element will be appended to the DOM only + * after {@link Fixture.connect} has been called. + */ + parent: HTMLElement; + + /** + * The first element in the {@link Fixture.view}. + */ + element: TElement; + + /** + * Adds the {@link Fixture.parent} to the DOM, causing the + * connect lifecycle to begin. + * @remarks + * Yields control to the caller one Microtask later, in order to + * ensure that the DOM has settled. + */ + connect: () => Promise; + + /** + * Removes the {@link Fixture.parent} from the DOM, causing the + * disconnect lifecycle to begin. + * @remarks + * Yields control to the caller one Microtask later, in order to + * ensure that the DOM has settled. + */ + disconnect: () => Promise; } function findElement(view: HTMLView): HTMLElement { - let current: Node | null = view.firstChild; + let current: Node | null = view.firstChild; - while (current !== null && current.nodeType !== 1) { - current = current.nextSibling; - } + while (current !== null && current.nodeType !== 1) { + current = current.nextSibling; + } - return current as any; + return current as any; } /** -* Creates a random, unique name suitable for use as a Custom Element name. -*/ + * Creates a random, unique name suitable for use as a Custom Element name. + */ export function uniqueElementName(): string { - return `fast-unique-${Math.random().toString(36).substring(7)}`; + return `fast-unique-${Math.random().toString(36).substring(7)}`; } /* eslint-disable @typescript-eslint/no-unused-vars */ // @ts-expect-error variable is never read function isElementRegistry( - obj: any + obj: any ): obj is FoundationElementRegistry { - return typeof obj.register === "function"; + return typeof obj.register === 'function'; } /** -* Creates a test fixture suitable for testing custom elements, templates, and bindings. -* @param templateNameOrRegistry An HTML template or single element name to create the fixture for. -* @param options Enables customizing fixture creation behavior. -* @remarks -* Yields control to the caller one Microtask later, in order to -* ensure that the DOM has settled. -*/ + * Creates a test fixture suitable for testing custom elements, templates, and bindings. + * @param templateNameOrRegistry An HTML template or single element name to create the fixture for. + * @param options Enables customizing fixture creation behavior. + * @remarks + * Yields control to the caller one Microtask later, in order to + * ensure that the DOM has settled. + */ export async function fixture( - templateNameOrRegistry: - | ViewTemplate - | string - | FoundationElementRegistry> - | [ - FoundationElementRegistry< - FoundationElementDefinition, - Constructable - >, - ...FoundationElementRegistry[] - ], - options: FixtureOptions = {} + templateNameOrRegistry: + | ViewTemplate + | string + | FoundationElementRegistry< + FoundationElementDefinition, + Constructable + > + | [ + FoundationElementRegistry< + FoundationElementDefinition, + Constructable + >, + ...FoundationElementRegistry< + FoundationElementDefinition, + Constructable + >[] + ], + options: FixtureOptions = {} ): Promise> { - const document = options.document || globalThis.document; - const parent = options.parent || document.createElement("div"); - const source = options.source || {}; - const context = options.context || defaultExecutionContext; - - if (typeof templateNameOrRegistry === "string") { - const html = `<${templateNameOrRegistry}>`; - templateNameOrRegistry = new ViewTemplate(html, []); - } else if (isElementRegistry(templateNameOrRegistry)) { - templateNameOrRegistry = [templateNameOrRegistry]; - } - - if (Array.isArray(templateNameOrRegistry)) { - const first = templateNameOrRegistry[0]; - const ds = options.designSystem || DesignSystem.getOrCreate(parent); - let prefix = ""; - - ds.register(templateNameOrRegistry, { - // @ts-expect-error variable is never read - register(container: Container, context: DesignSystemRegistrationContext) { - prefix = context.elementPrefix; - }, - }); - - const elementName = `${prefix}-${first.definition.baseName}`; - const html = `<${elementName}>`; - templateNameOrRegistry = new ViewTemplate(html, []); - } - - const view = templateNameOrRegistry.create(); - const element = findElement(view) as any; - let isConnected = false; - - view.bind(source, context); - view.appendTo(parent); - - customElements.upgrade(parent); - - // Hook into the Microtask Queue to ensure the DOM is settled - // before yielding control to the caller. - await Promise.resolve(); - - const connect = async () => { - if (isConnected) { - return; - } - - isConnected = true; - document.body.appendChild(parent); - await Promise.resolve(); - }; - - const disconnect = async () => { - if (!isConnected) { - return; - } - - isConnected = false; - document.body.removeChild(parent); - await Promise.resolve(); - }; - - return { - document, - template: templateNameOrRegistry, - view, - parent, - element, - connect, - disconnect, - }; -} \ No newline at end of file + const document = options.document || globalThis.document; + const parent = options.parent || document.createElement('div'); + const source = options.source || {}; + const context = options.context || defaultExecutionContext; + + if (typeof templateNameOrRegistry === 'string') { + const html = `<${templateNameOrRegistry}>`; + templateNameOrRegistry = new ViewTemplate(html, []); + } else if (isElementRegistry(templateNameOrRegistry)) { + templateNameOrRegistry = [templateNameOrRegistry]; + } + + if (Array.isArray(templateNameOrRegistry)) { + const first = templateNameOrRegistry[0]; + const ds = options.designSystem || DesignSystem.getOrCreate(parent); + let prefix = ''; + + ds.register(templateNameOrRegistry, { + // @ts-expect-error variable is never read + register(container: Container, context: DesignSystemRegistrationContext) { + prefix = context.elementPrefix; + }, + }); + + const elementName = `${prefix}-${first.definition.baseName}`; + const html = `<${elementName}>`; + templateNameOrRegistry = new ViewTemplate(html, []); + } + + const view = templateNameOrRegistry.create(); + const element = findElement(view) as any; + let isConnected = false; + + view.bind(source, context); + view.appendTo(parent); + + customElements.upgrade(parent); + + // Hook into the Microtask Queue to ensure the DOM is settled + // before yielding control to the caller. + await Promise.resolve(); + + const connect = async () => { + if (isConnected) { + return; + } + + isConnected = true; + document.body.appendChild(parent); + await Promise.resolve(); + }; + + const disconnect = async () => { + if (!isConnected) { + return; + } + + isConnected = false; + document.body.removeChild(parent); + await Promise.resolve(); + }; + + return { + document, + template: templateNameOrRegistry, + view, + parent, + element, + connect, + disconnect, + }; +} From 21278a76cbcd22ce97318e7746349998681b0926 Mon Sep 17 00:00:00 2001 From: TaylorJ76 Date: Wed, 30 Oct 2024 09:57:13 +0000 Subject: [PATCH 10/14] chore: updated tests --- .../shared/foundation/button/button.spec.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/libs/components/src/shared/foundation/button/button.spec.ts b/libs/components/src/shared/foundation/button/button.spec.ts index 76807b3134..090a3e5078 100644 --- a/libs/components/src/shared/foundation/button/button.spec.ts +++ b/libs/components/src/shared/foundation/button/button.spec.ts @@ -528,6 +528,40 @@ describe('Foundation Button', () => { }); }); + 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', + // @ts-expect-error overload + (event: Event & { submitter: HTMLElement }) => { + 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).toEqual(true); + + await disconnect(); + }); + }); + describe("of type 'reset'", () => { it('should reset the parent form when clicked', async () => { const { connect, disconnect, element, parent } = await setup(); @@ -555,6 +589,27 @@ describe('Foundation Button', () => { }); 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(); From e4eeb1a2ec969916d6ca005f1d9cf81c6cfc4c9e Mon Sep 17 00:00:00 2001 From: Richard Helm Date: Tue, 5 Nov 2024 09:59:09 +0000 Subject: [PATCH 11/14] Remove unused start-end pattern --- .../foundation/button/button.template.ts | 5 +- .../src/shared/foundation/button/button.ts | 9 +- .../src/shared/foundation/patterns/index.ts | 1 - .../shared/foundation/patterns/start-end.ts | 147 ------------------ 4 files changed, 5 insertions(+), 157 deletions(-) delete mode 100644 libs/components/src/shared/foundation/patterns/start-end.ts diff --git a/libs/components/src/shared/foundation/button/button.template.ts b/libs/components/src/shared/foundation/button/button.template.ts index f23485f168..5554c3829d 100644 --- a/libs/components/src/shared/foundation/button/button.template.ts +++ b/libs/components/src/shared/foundation/button/button.template.ts @@ -1,7 +1,6 @@ import { html, ref, slotted } from '@microsoft/fast-element'; import type { ViewTemplate } from '@microsoft/fast-element'; import type { FoundationElementTemplate } from '@microsoft/fast-foundation'; -import { endSlotTemplate, startSlotTemplate } from '../patterns/start-end'; import type { ButtonOptions, FoundationButton } from './button'; /** @@ -11,7 +10,7 @@ import type { ButtonOptions, FoundationButton } from './button'; export const buttonTemplate: FoundationElementTemplate< ViewTemplate, ButtonOptions -> = (context, definition) => html` +> = () => html` `; diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts index c521de5504..add6bd0340 100644 --- a/libs/components/src/shared/foundation/button/button.ts +++ b/libs/components/src/shared/foundation/button/button.ts @@ -2,15 +2,14 @@ import { attr, observable } from '@microsoft/fast-element'; import { applyMixins } from '@microsoft/fast-foundation'; import type { FoundationElementDefinition } from '@microsoft/fast-foundation'; -import { ARIAGlobalStatesAndProperties, StartEnd } from '../patterns/index'; -import type { StartEndOptions } from '../patterns/index'; +import { ARIAGlobalStatesAndProperties } from '../patterns/index'; import { FormAssociatedButton } from './button.form-associated'; /** * Button configuration options * @public */ -export type ButtonOptions = FoundationElementDefinition & StartEndOptions; +export type ButtonOptions = FoundationElementDefinition; /** * A Button Custom HTML Element. @@ -313,5 +312,5 @@ applyMixins(DelegatesARIAButton, ARIAGlobalStatesAndProperties); * TODO: https://github.com/microsoft/fast/issues/3317 * @internal */ -export interface FoundationButton extends StartEnd, DelegatesARIAButton {} -applyMixins(FoundationButton, StartEnd, DelegatesARIAButton); +export interface FoundationButton extends DelegatesARIAButton {} +applyMixins(FoundationButton, DelegatesARIAButton); diff --git a/libs/components/src/shared/foundation/patterns/index.ts b/libs/components/src/shared/foundation/patterns/index.ts index c1980f2f69..4f453051f2 100644 --- a/libs/components/src/shared/foundation/patterns/index.ts +++ b/libs/components/src/shared/foundation/patterns/index.ts @@ -1,2 +1 @@ export * from './aria-global.js'; -export * from './start-end.js'; diff --git a/libs/components/src/shared/foundation/patterns/start-end.ts b/libs/components/src/shared/foundation/patterns/start-end.ts deleted file mode 100644 index 45d9a81054..0000000000 --- a/libs/components/src/shared/foundation/patterns/start-end.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { html, ref } from '@microsoft/fast-element'; -import type { - SyntheticViewTemplate, - ViewTemplate, -} from '@microsoft/fast-element'; -import type { ElementDefinitionContext } from '@microsoft/fast-foundation'; -/** - * Start configuration options - * @public - */ -export type StartOptions = { - start?: string | SyntheticViewTemplate; -}; - -/** - * End configuration options - * @public - */ -export type EndOptions = { - end?: string | SyntheticViewTemplate; -}; - -/** - * Start/End configuration options - * @public - */ -export type StartEndOptions = StartOptions & EndOptions; - -/** - * A mixin class implementing start and end elements. - * These are generally used to decorate text elements with icons or other visual indicators. - * @public - */ -export class StartEnd { - /* eslint-disable @typescript-eslint/explicit-member-accessibility */ - // @ts-expect-error Type is incorrectly non-optional - public start: HTMLSlotElement; - // @ts-expect-error Type is incorrectly non-optional - public startContainer: HTMLSpanElement; - public handleStartContentChange(): void { - this.startContainer.classList.toggle( - 'start', - this.start.assignedNodes().length > 0 - ); - } - // @ts-expect-error Type is incorrectly non-optional - public end: HTMLSlotElement; - // @ts-expect-error Type is incorrectly non-optional - public endContainer: HTMLSpanElement; - public handleEndContentChange(): void { - this.endContainer.classList.toggle( - 'end', - this.end.assignedNodes().length > 0 - ); - } - /* eslint-enable */ -} - -/** - * The template for the end element. - * For use with {@link StartEnd} - * - * @public - */ -export const endSlotTemplate: ( - context: ElementDefinitionContext, - definition: EndOptions -) => ViewTemplate = ( - _context: ElementDefinitionContext, - definition: EndOptions -) => html` - (definition.end ? 'end' : void 0)} - > - - ${definition.end || ''} - - -`; - -/** - * The template for the start element. - * For use with {@link StartEnd} - * - * @public - */ -export const startSlotTemplate: ( - context: ElementDefinitionContext, - definition: StartOptions -) => ViewTemplate = ( - _context: ElementDefinitionContext, - definition: StartOptions -) => html` - - - ${definition.start || ''} - - -`; - -/** - * The template for the end element. - * For use with {@link StartEnd} - * - * @public - * @deprecated - use endSlotTemplate - */ -export const endTemplate: ViewTemplate = html` - - - -`; - -/** - * The template for the start element. - * For use with {@link StartEnd} - * - * @public - * @deprecated - use startSlotTemplate - */ -export const startTemplate: ViewTemplate = html` - - - -`; From 9e9155b675f45d70757b3dfc44d6c4851a166164 Mon Sep 17 00:00:00 2001 From: Richard Helm Date: Tue, 5 Nov 2024 09:59:56 +0000 Subject: [PATCH 12/14] Remove unused handleUnsupportedDelegatesFocus --- .../src/shared/foundation/button/button.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts index add6bd0340..53ab5e827a 100644 --- a/libs/components/src/shared/foundation/button/button.ts +++ b/libs/components/src/shared/foundation/button/button.ts @@ -181,7 +181,6 @@ export class FoundationButton extends FormAssociatedButton { super.connectedCallback(); this.proxy.setAttribute('type', this.type); - this.handleUnsupportedDelegatesFocus(); const elements = Array.from(this.control?.children) as HTMLSpanElement[]; if (elements) { @@ -249,25 +248,6 @@ export class FoundationButton extends FormAssociatedButton { // @ts-expect-error Type is incorrectly non-optional public control: HTMLButtonElement; - - /** - * Overrides the focus call for where delegatesFocus is unsupported. - * This check works for Chrome, Edge Chromium, FireFox, and Safari - * Relevant PR on the Firefox browser: https://phabricator.services.mozilla.com/D123858 - */ - private handleUnsupportedDelegatesFocus = () => { - // Check to see if delegatesFocus is supported - if ( - window.ShadowRoot && - /* eslint-disable-next-line no-prototype-builtins */ - !window.ShadowRoot.prototype.hasOwnProperty('delegatesFocus') && - this.$fastController.definition.shadowOptions?.delegatesFocus - ) { - this.focus = () => { - this.control.focus(); - }; - } - }; } /** From 6aa9a057f34d8d863048e28c3573371c60f677a7 Mon Sep 17 00:00:00 2001 From: Richard Helm Date: Tue, 5 Nov 2024 10:29:04 +0000 Subject: [PATCH 13/14] Remove unused defaultSlottedContent --- .../foundation/button/button.template.ts | 4 ++-- .../src/shared/foundation/button/button.ts | 20 +++---------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/libs/components/src/shared/foundation/button/button.template.ts b/libs/components/src/shared/foundation/button/button.template.ts index 5554c3829d..4ad736f5b3 100644 --- a/libs/components/src/shared/foundation/button/button.template.ts +++ b/libs/components/src/shared/foundation/button/button.template.ts @@ -1,4 +1,4 @@ -import { html, ref, slotted } from '@microsoft/fast-element'; +import { html, ref } from '@microsoft/fast-element'; import type { ViewTemplate } from '@microsoft/fast-element'; import type { FoundationElementTemplate } from '@microsoft/fast-foundation'; import type { ButtonOptions, FoundationButton } from './button'; @@ -49,7 +49,7 @@ export const buttonTemplate: FoundationElementTemplate< ${ref('control')} > - + `; diff --git a/libs/components/src/shared/foundation/button/button.ts b/libs/components/src/shared/foundation/button/button.ts index 53ab5e827a..8d6e487467 100644 --- a/libs/components/src/shared/foundation/button/button.ts +++ b/libs/components/src/shared/foundation/button/button.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ -import { attr, observable } from '@microsoft/fast-element'; +import { attr } from '@microsoft/fast-element'; import { applyMixins } from '@microsoft/fast-foundation'; import type { FoundationElementDefinition } from '@microsoft/fast-foundation'; import { ARIAGlobalStatesAndProperties } from '../patterns/index'; @@ -15,9 +15,6 @@ export type ButtonOptions = FoundationElementDefinition; * A Button Custom HTML Element. * Based largely on the {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button |