diff --git a/change/@ni-nimble-components-b40541e2-0d52-423d-a278-53d68df3aa8e.json b/change/@ni-nimble-components-b40541e2-0d52-423d-a278-53d68df3aa8e.json new file mode 100644 index 0000000000..4f2adcfffc --- /dev/null +++ b/change/@ni-nimble-components-b40541e2-0d52-423d-a278-53d68df3aa8e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Fork number-field and text-field templates", + "packageName": "@ni/nimble-components", + "email": "20542556+mollykreis@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/src/number-field/index.ts b/packages/nimble-components/src/number-field/index.ts index d1b59bf7eb..0501a522ca 100644 --- a/packages/nimble-components/src/number-field/index.ts +++ b/packages/nimble-components/src/number-field/index.ts @@ -2,8 +2,7 @@ import { attr, html } from '@microsoft/fast-element'; import { DesignSystem, NumberField as FoundationNumberField, - NumberFieldOptions, - numberFieldTemplate as template + NumberFieldOptions } from '@microsoft/fast-foundation'; import { styles } from './styles'; import { NumberFieldAppearance } from './types'; @@ -17,6 +16,7 @@ import { numericDecrementLabel, numericIncrementLabel } from '../label-provider/core/label-tokens'; +import { template } from './template'; declare global { interface HTMLElementTagNameMap { diff --git a/packages/nimble-components/src/number-field/template.ts b/packages/nimble-components/src/number-field/template.ts new file mode 100644 index 0000000000..21449bc272 --- /dev/null +++ b/packages/nimble-components/src/number-field/template.ts @@ -0,0 +1,102 @@ +import { html, ref, slotted, when } from '@microsoft/fast-element'; +import type { ViewTemplate } from '@microsoft/fast-element'; +import { + endSlotTemplate, + FoundationElementTemplate, + NumberFieldOptions, + startSlotTemplate +} from '@microsoft/fast-foundation'; +import type { NumberField } from '.'; + +/** + * The template for the {@link @microsoft/fast-foundation#(NumberField:class)} component. + * @public + */ +export const template: FoundationElementTemplate< +ViewTemplate, +NumberFieldOptions +> = (context, definition) => html` + +`; diff --git a/packages/nimble-components/src/number-field/tests/number-field.foundation.spec.ts b/packages/nimble-components/src/number-field/tests/number-field.foundation.spec.ts new file mode 100644 index 0000000000..d8fc51ec3d --- /dev/null +++ b/packages/nimble-components/src/number-field/tests/number-field.foundation.spec.ts @@ -0,0 +1,975 @@ +// Based on tests in FAST repo: https://github.com/microsoft/fast/blob/fd9068b94e4aa8d2282f0cce613f58436fae955d/packages/web-components/fast-foundation/src/number-field/number-field.spec.ts + +import { DOM } from '@microsoft/fast-element'; +import { NumberField } from '..'; +import { template } from '../template'; +import { fixture } from '../../utilities/tests/fixture'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const FASTNumberField = NumberField.compose({ + baseName: 'number-field', + template +}); + +async function setup(props?: Partial): Promise<{ + element: NumberField, + connect: () => Promise, + disconnect: () => Promise, + parent: HTMLElement +}> { + const { element, connect, disconnect, parent } = await fixture(FASTNumberField()); + + if (props) { + for (const key in props) { + if (Object.prototype.hasOwnProperty.call(props, key)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + (element as any)[key] = (props as any)[key].toString(); + } + } + } + + await connect(); + + return { element, connect, disconnect, parent }; +} + +describe('NumberField', () => { + it('should set the `autofocus` attribute on the internal control equal to the value provided', async () => { + const { element, disconnect } = await setup({ autofocus: true }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('autofocus') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `disabled` attribute on the internal control equal to the value provided', async () => { + const { element, disconnect } = await setup({ disabled: true }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('disabled') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `list` attribute on the internal control equal to the value provided', async () => { + const list = 'listId'; + const { element, disconnect } = await setup({ list }); + + expect( + element.shadowRoot!.querySelector('.control')?.getAttribute('list') + ).toBe(list); + + await disconnect(); + }); + + it('should set the `maxlength` attribute on the internal control equal to the value provided', async () => { + const maxlength = 14; + const { element, disconnect } = await setup({ maxlength }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('maxlength') + ).toBe(maxlength.toString()); + + await disconnect(); + }); + + it('should set the `minlength` attribute on the internal control equal to the value provided', async () => { + const minlength = 8; + const { element, disconnect } = await setup({ minlength }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('minlength') + ).toBe(minlength.toString()); + + await disconnect(); + }); + + it('should set the `placeholder` attribute on the internal control equal to the value provided', async () => { + const placeholder = 'placeholder'; + const { element, disconnect } = await setup({ placeholder }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('placeholder') + ).toBe(placeholder); + + await disconnect(); + }); + + it('should set the `readonly` attribute on the internal control equal to the value provided', async () => { + const { element, disconnect } = await setup({ readOnly: true }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('readonly') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `required` attribute on the internal control equal to the value provided', async () => { + const { element, disconnect } = await setup({ required: true }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('required') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `size` attribute on the internal control equal to the value provided', async () => { + const { element, disconnect } = await setup({ size: 8 }); + + expect( + element.shadowRoot!.querySelector('.control')?.hasAttribute('size') + ).toBeTrue(); + + await disconnect(); + }); + + it('should initialize to the initial value if no value property is set', async () => { + const { element, disconnect } = await setup(); + + expect(element.value).toBe(element.initialValue); + + await disconnect(); + }); + + it('should initialize to the provided value attribute if set pre-connection', async () => { + const value = '10'; + const { element, disconnect } = await setup({ value }); + + expect(element.value).toBe(value); + + await disconnect(); + }); + + it('should initialize to the provided value attribute if set post-connection', async () => { + const value = '10'; + const { element, disconnect } = await setup(); + + element.setAttribute('value', value); + + expect(element.value).toBe(value); + + await disconnect(); + }); + + it('should initialize to the provided value property if set pre-connection', async () => { + const value = '10'; + const { element, disconnect } = await setup({ value }); + + expect(element.value).toBe(value); + + await disconnect(); + }); + + describe('Delegates ARIA textbox', () => { + it('should set the `aria-atomic` attribute on the internal control when provided', async () => { + const ariaAtomic = 'true'; + const { element, disconnect } = await setup({ ariaAtomic }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-atomic') + ).toBe(ariaAtomic); + + await disconnect(); + }); + + it('should set the `aria-busy` attribute on the internal control when provided', async () => { + const ariaBusy = 'false'; + const { element, disconnect } = await setup({ ariaBusy }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-busy') + ).toBe(ariaBusy); + + await disconnect(); + }); + + it('should set the `aria-controls` attribute on the internal control when provided', async () => { + const ariaControls = 'testId'; + const { element, disconnect } = await setup({ ariaControls }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-controls') + ).toBe(ariaControls); + + await disconnect(); + }); + + it('should set the `aria-current` attribute on the internal control when provided', async () => { + const ariaCurrent = 'page'; + const { element, disconnect } = await setup({ ariaCurrent }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-current') + ).toBe(ariaCurrent); + + await disconnect(); + }); + + it('should set the `aria-describedby` attribute on the internal control when provided', async () => { + const ariaDescribedby = 'testId'; + const { element, disconnect } = await setup({ ariaDescribedby }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-describedby') + ).toBe(ariaDescribedby); + + await disconnect(); + }); + + it('should set the `aria-details` attribute on the internal control when provided', async () => { + const ariaDetails = 'testId'; + const { element, disconnect } = await setup({ ariaDetails }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-details') + ).toBe(ariaDetails); + + await disconnect(); + }); + + it('should set the `aria-disabled` attribute on the internal control when provided', async () => { + const ariaDisabled = 'true'; + const { element, disconnect } = await setup({ ariaDisabled }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-disabled') + ).toBe(ariaDisabled); + + await disconnect(); + }); + + it('should set the `aria-errormessage` attribute on the internal control when provided', async () => { + const ariaErrormessage = 'test'; + const { element, disconnect } = await setup({ ariaErrormessage }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-errormessage') + ).toBe(ariaErrormessage); + + await disconnect(); + }); + + it('should set the `aria-flowto` attribute on the internal control when provided', async () => { + const ariaFlowto = 'testId'; + const { element, disconnect } = await setup({ ariaFlowto }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-flowto') + ).toBe(ariaFlowto); + + await disconnect(); + }); + + it('should set the `aria-haspopup` attribute on the internal control when provided', async () => { + const ariaHaspopup = 'true'; + const { element, disconnect } = await setup({ ariaHaspopup }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-haspopup') + ).toBe(ariaHaspopup); + + await disconnect(); + }); + + it('should set the `aria-hidden` attribute on the internal control when provided', async () => { + const ariaHidden = 'true'; + const { element, disconnect } = await setup({ ariaHidden }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-hidden') + ).toBe(ariaHidden); + + await disconnect(); + }); + + it('should set the `aria-invalid` attribute on the internal control when provided', async () => { + const ariaInvalid = 'spelling'; + const { element, disconnect } = await setup({ ariaInvalid }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-invalid') + ).toBe(ariaInvalid); + + await disconnect(); + }); + + it('should set the `aria-keyshortcuts` attribute on the internal control when provided', async () => { + const ariaKeyshortcuts = 'F4'; + const { element, disconnect } = await setup({ ariaKeyshortcuts }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-keyshortcuts') + ).toBe(ariaKeyshortcuts); + + await disconnect(); + }); + + it('should set the `aria-label` attribute on the internal control when provided', async () => { + const ariaLabel = 'Foo label'; + const { element, disconnect } = await setup({ ariaLabel }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-label') + ).toBe(ariaLabel); + + await disconnect(); + }); + + it('should set the `aria-labelledby` attribute on the internal control when provided', async () => { + const ariaLabelledby = 'testId'; + const { element, disconnect } = await setup({ ariaLabelledby }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-labelledby') + ).toBe(ariaLabelledby); + + await disconnect(); + }); + + it('should set the `aria-live` attribute on the internal control when provided', async () => { + const ariaLive = 'polite'; + const { element, disconnect } = await setup({ ariaLive }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-live') + ).toBe(ariaLive); + + await disconnect(); + }); + + it('should set the `aria-owns` attribute on the internal control when provided', async () => { + const ariaOwns = 'testId'; + const { element, disconnect } = await setup({ ariaOwns }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-owns') + ).toBe(ariaOwns); + + await disconnect(); + }); + + it('should set the `aria-relevant` attribute on the internal control when provided', async () => { + const ariaRelevant = 'removals'; + const { element, disconnect } = await setup({ ariaRelevant }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-relevant') + ).toBe(ariaRelevant); + + await disconnect(); + }); + + it('should set the `aria-roledescription` attribute on the internal control when provided', async () => { + const ariaRoledescription = 'slide'; + const { element, disconnect } = await setup({ + ariaRoledescription + }); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-roledescription') + ).toBe(ariaRoledescription); + + await disconnect(); + }); + }); + + describe('events', () => { + it('should fire a change event when the internal control emits a change event', async () => { + const { element, disconnect } = await setup(); + const event = new Event('change', { + key: '1' + } as KeyboardEventInit); + let wasChanged = false; + + element.addEventListener('change', e => { + e.preventDefault(); + + wasChanged = true; + }); + + const textarea = element.shadowRoot!.querySelector('input'); + textarea?.dispatchEvent(event); + + expect(wasChanged).toBeTrue(); + + await disconnect(); + }); + + it('should fire an input event when incrementing or decrementing', async () => { + const { element, disconnect } = await setup(); + let wasInput = false; + + element.addEventListener('input', e => { + e.preventDefault(); + + wasInput = true; + }); + + element.stepUp(); + + expect(wasInput).toBeTrue(); + + wasInput = false; + + element.stepDown(); + + expect(wasInput).toBeTrue(); + + await disconnect(); + }); + }); + + describe("when the owning form's reset() method is invoked", () => { + it("should reset it's value property to an empty string if no value attribute is set", async () => { + const { element, disconnect, parent } = await setup(); + + const form = document.createElement('form'); + form.appendChild(element); + parent.appendChild(form); + + const value = '10'; + element.value = value; + expect(element.value).toBe(value); + + form.reset(); + + expect(element.value).toBe(''); + + await disconnect(); + }); + + it("should reset it's value property to the value of the value attribute if it is set", async () => { + const { element, disconnect, parent } = await setup(); + + const form = document.createElement('form'); + form.appendChild(element); + parent.appendChild(form); + + element.setAttribute('value', '10'); + + element.value = '20'; + expect(element.getAttribute('value')).toBe('10'); + expect(element.value).toBe('20'); + + form.reset(); + expect(element.value).toBe('10'); + + await disconnect(); + }); + + it('should update input field when script sets value', async () => { + const { element, disconnect } = await setup(); + const value = '10'; + + expect( + element.shadowRoot!.querySelector('.control')! + .value + ).toBe(''); + + element.setAttribute('value', value); + + await DOM.nextUpdate(); + + expect( + element.shadowRoot!.querySelector('.control')! + .value + ).toBe(value); + + await disconnect(); + }); + + it('should put the control into a clean state, where value attribute changes the property value prior to user or programmatic interaction', async () => { + const { element, disconnect, parent } = await setup(); + const form = document.createElement('form'); + form.appendChild(element); + parent.appendChild(form); + element.setAttribute('value', '10'); + + element.value = '20'; + expect(element.value).toBe('20'); + + form.reset(); + + expect(element.value).toBe('10'); + + element.setAttribute('value', '30'); + expect(element.value).toBe('30'); + + await disconnect(); + }); + }); + + describe('min and max values', () => { + it('should set min value', async () => { + const min = 1; + const { element, disconnect } = await setup({ min }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('min') + ).toBe(min.toString()); + + await disconnect(); + }); + + it('should set max value', async () => { + const max = 10; + const { element, disconnect } = await setup({ max }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('max') + ).toBe(max.toString()); + + await disconnect(); + }); + + it('should set value to max when value is greater than max', async () => { + const max = 10; + const value = '20'; + const { element, disconnect } = await setup({ value, max }); + + expect(element.value).toBe(max.toString()); + + await disconnect(); + }); + + it('should set value to max if the max changes to a value less than the value', async () => { + const max = 10; + const value = `${10 + max}`; + const { element, disconnect } = await setup({ value }); + + expect(element.value).toBe(value.toString()); + + element.setAttribute('max', max.toString()); + await DOM.nextUpdate(); + + expect(element.value).toBe(max.toString()); + + await disconnect(); + }); + + it('should set value to min when value is less than min', async () => { + const min = 10; + const value = `${min - 8}`; + const { element, disconnect } = await setup({ value, min }); + + expect(element.value).toBe(min.toString()); + + element.value = `${min - 100}`; + await DOM.nextUpdate(); + + expect(element.value).toBe(min.toString()); + await disconnect(); + }); + + it('should set value to min if the min changes to a value more than the value', async () => { + const min = 20; + const value = `${min - 10}`; + const { element, disconnect } = await setup({ value }); + + expect(element.value).toBe(value.toString()); + + element.setAttribute('min', min.toString()); + await DOM.nextUpdate(); + + expect(element.value).toBe(min.toString()); + + await disconnect(); + }); + + it('should set max to highest when min is greater than max', async () => { + const min = 10; + const max = 1; + const { element, disconnect } = await setup({ min, max }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('max') + ).toBe(min.toString()); + + await disconnect(); + }); + }); + + describe('step and increment/decrement', () => { + it('should set step to a default of 1', async () => { + const { element, disconnect } = await setup(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('step') + ).toBe('1'); + + await disconnect(); + }); + + it('should update step', async () => { + const step = 2; + const { element, disconnect } = await setup({ step }); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('step') + ).toBe(step.toString()); + + await disconnect(); + }); + + it('should increment the value by the step amount', async () => { + const step = 2; + const value = 5; + const { element, disconnect } = await setup({ + step, + value: value.toString() + }); + + element.stepUp(); + + expect(element.value).toBe(`${value + step}`); + + await disconnect(); + }); + + it('should decrement the value by the step amount', async () => { + const step = 2; + const value = 5; + const { element, disconnect } = await setup({ + step, + value: value.toString() + }); + + element.stepDown(); + + expect(element.value).toBe(`${value - step}`); + + await disconnect(); + }); + + it('should increment no value to the step amount', async () => { + const step = 2; + const { element, disconnect } = await setup({ step }); + + element.stepUp(); + + expect(element.value).toBe(`${step}`); + + await disconnect(); + }); + + it('should decrement no value to the negative step amount', async () => { + const step = 2; + const { element, disconnect } = await setup({ step }); + + element.stepDown(); + await DOM.nextUpdate(); + + expect(element.value).toBe(`${0 - step}`); + + await disconnect(); + }); + + it('should decrement to zero when no value and negative min', async () => { + const min = -10; + const { element, disconnect } = await setup({ min }); + + element.stepDown(); + await DOM.nextUpdate(); + + expect(element.value).toBe('0'); + + await disconnect(); + }); + + it('should increment to zero when no value and negative min', async () => { + const min = -10; + const { element, disconnect } = await setup({ min }); + + element.stepUp(); + await DOM.nextUpdate(); + + expect(element.value).toBe('0'); + + await disconnect(); + }); + + it('should decrement to min when no value and min > 0', async () => { + const min = 10; + const { element, disconnect } = await setup({ min }); + + element.stepDown(); + await DOM.nextUpdate(); + + expect(element.value).toBe(min.toString()); + + await disconnect(); + }); + + it('should increment to min when no value and min > 0', async () => { + const min = 10; + const { element, disconnect } = await setup({ min }); + + element.stepUp(); + await DOM.nextUpdate(); + + expect(element.value).toBe(min.toString()); + + await disconnect(); + }); + + it('should decrement to max when no value and min and max < 0', async () => { + const min = -100; + const max = -10; + const { element, disconnect } = await setup({ min, max }); + + element.stepDown(); + await DOM.nextUpdate(); + + expect(element.value).toBe(max.toString()); + + await disconnect(); + }); + + it('should increment to mx when no value and min and max < 0', async () => { + const min = -100; + const max = -10; + const { element, disconnect } = await setup({ min, max }); + + element.stepUp(); + await DOM.nextUpdate(); + + expect(element.value).toBe(max.toString()); + + await disconnect(); + }); + + it('should update the proxy value when incrementing the value', async () => { + const step = 2; + const value = 5; + const { element, disconnect } = await setup({ + step, + value: value.toString() + }); + + element.stepUp(); + + expect(element.value).toBe(`${value + step}`); + expect(element.proxy.value).toBe(`${value + step}`); + + await disconnect(); + }); + + it('should update the proxy value when decrementing the value', async () => { + const step = 2; + const value = 5; + const { element, disconnect } = await setup({ + step, + value: value.toString() + }); + + element.stepDown(); + + expect(element.value).toBe(`${value - step}`); + expect(element.proxy.value).toBe(`${value - step}`); + + await disconnect(); + }); + + it('should correct rounding errors', async () => { + const step = 0.1; + let value = (0.2).toString(); + const { element, disconnect } = await setup({ step, value }); + const incrementValue = (): void => { + element.stepUp(); + value = (parseFloat(value) + step).toPrecision(1); + }; + + expect(element.value).toBe(value); + + incrementValue(); + expect(element.value).toBe(value); + + incrementValue(); + expect(element.value).toBe(value); + + incrementValue(); + expect(element.value).toBe(value); + + incrementValue(); + expect(element.value).toBe(value); + + await disconnect(); + }); + }); + + describe('value validation', () => { + it('should allow number entry', async () => { + const value = '18'; + const { element, disconnect } = await setup(); + + element.setAttribute('value', value); + + expect(element.value).toBe(value); + + await disconnect(); + }); + + it('should not allow non-number entry', async () => { + const { element, disconnect } = await setup(); + + element.setAttribute('value', '11a'); + expect(element.value).toBe('11'); + + await disconnect(); + }); + + it('should allow float number entry', async () => { + const { element, disconnect } = await setup(); + const floatValue = '37.5'; + + element.setAttribute('value', floatValue); + expect(element.value).toBe(floatValue); + + await disconnect(); + }); + + it('should allow negative number entry', async () => { + const { element, disconnect } = await setup(); + + element.setAttribute('value', '-1'); + expect(element.value).toBe('-1'); + + await disconnect(); + }); + + it('should allow negative float entry', async () => { + const { element, disconnect } = await setup(); + const negativeFloatValue = '-1.5'; + + element.setAttribute('value', negativeFloatValue); + expect(element.value).toBe(negativeFloatValue); + + await disconnect(); + }); + }); + + describe('hide step', () => { + it('should not render step controls when `hide-step` attribute is present', async () => { + const { element, disconnect } = await setup(); + + expect(element.shadowRoot!.querySelector('.controls')).not.toBe( + null + ); + + element.setAttribute('hide-step', ''); + + await DOM.nextUpdate(); + + expect(element.shadowRoot!.querySelector('.controls')).toBe(null); + + await disconnect(); + }); + }); + + describe('readonly', () => { + it('should not render step controls when `readonly` attribute is present', async () => { + const { element, disconnect } = await setup(); + + expect(element.shadowRoot!.querySelector('.controls')).not.toBe( + null + ); + + element.setAttribute('readonly', ''); + + await DOM.nextUpdate(); + + expect(element.shadowRoot!.querySelector('.controls')).toBe(null); + + await disconnect(); + }); + }); + + describe('valueAsNumber', () => { + it('should allow setting value with number', async () => { + const { element, disconnect } = await setup(); + + element.valueAsNumber = 18; + + expect(element.value).toBe('18'); + + await disconnect(); + }); + + it('should allow reading value as number', async () => { + const { element, disconnect } = await setup(); + + element.value = '18'; + + expect(element.valueAsNumber).toBe(18); + + await disconnect(); + }); + }); +}); diff --git a/packages/nimble-components/src/text-field/index.ts b/packages/nimble-components/src/text-field/index.ts index 56330d0d3b..1f18f15d6b 100644 --- a/packages/nimble-components/src/text-field/index.ts +++ b/packages/nimble-components/src/text-field/index.ts @@ -2,14 +2,14 @@ import { attr, html } from '@microsoft/fast-element'; import { DesignSystem, TextField as FoundationTextField, - TextFieldOptions, - textFieldTemplate as template + TextFieldOptions } from '@microsoft/fast-foundation'; import { styles } from './styles'; import { TextFieldAppearance } from './types'; import { errorTextTemplate } from '../patterns/error/template'; import { mixinErrorPattern } from '../patterns/error/types'; import { iconExclamationMarkTag } from '../icons/exclamation-mark'; +import { template } from './template'; declare global { interface HTMLElementTagNameMap { diff --git a/packages/nimble-components/src/text-field/template.ts b/packages/nimble-components/src/text-field/template.ts new file mode 100644 index 0000000000..a2c6207ca4 --- /dev/null +++ b/packages/nimble-components/src/text-field/template.ts @@ -0,0 +1,84 @@ +import { html, ref, slotted } from '@microsoft/fast-element'; +import type { ViewTemplate } from '@microsoft/fast-element'; +import { + FoundationElementTemplate, + TextFieldOptions, + whitespaceFilter, + startSlotTemplate, + endSlotTemplate +} from '@microsoft/fast-foundation'; +import type { TextField } from '.'; + +/** + * The template for the {@link @microsoft/fast-foundation#(TextField:class)} component. + * @public + */ +export const template: FoundationElementTemplate< +ViewTemplate, +TextFieldOptions +> = (context, definition) => html` + +`; diff --git a/packages/nimble-components/src/text-field/tests/text-field.foundation.spec.ts b/packages/nimble-components/src/text-field/tests/text-field.foundation.spec.ts new file mode 100644 index 0000000000..9768a47438 --- /dev/null +++ b/packages/nimble-components/src/text-field/tests/text-field.foundation.spec.ts @@ -0,0 +1,918 @@ +// Based on tests in FAST repo: https://github.com/microsoft/fast/blob/fd9068b94e4aa8d2282f0cce613f58436fae955d/packages/web-components/fast-foundation/src/text-field/text-field.spec.ts + +import { TextFieldType } from '@microsoft/fast-foundation'; +import { TextField } from '..'; +import { fixture } from '../../utilities/tests/fixture'; +import { template } from '../template'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const FASTTextField = TextField.compose({ + baseName: 'text-field', + template +}); + +async function setup(): Promise<{ + element: TextField, + connect: () => Promise, + disconnect: () => Promise, + parent: HTMLElement +}> { + const { element, connect, disconnect, parent } = await fixture(FASTTextField()); + + return { element, connect, disconnect, parent }; +} + +describe('TextField', () => { + it('should set the `autofocus` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const autofocus = true; + + element.autofocus = autofocus; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('autofocus') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `disabled` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const disabled = true; + + element.disabled = disabled; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('disabled') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `list` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const list = 'listId'; + + element.list = list; + + await connect(); + expect( + element.shadowRoot!.querySelector('.control')?.getAttribute('list') + ).toEqual(list); + + await disconnect(); + }); + + it('should set the `maxlength` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const maxlength = 14; + + element.maxlength = maxlength; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('maxlength') + ).toEqual(maxlength.toString()); + + await disconnect(); + }); + + it('should set the `minlength` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const minlength = 8; + + element.minlength = minlength; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('minlength') + ).toEqual(minlength.toString()); + + await disconnect(); + }); + + it('should set the `placeholder` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const placeholder = 'placeholder'; + + element.placeholder = placeholder; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('placeholder') + ).toEqual(placeholder); + + await disconnect(); + }); + + it('should set the `readonly` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const readonly = true; + + element.readOnly = readonly; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('readonly') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `required` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const required = true; + + element.required = required; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('required') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `size` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const size = 8; + + element.size = size; + + await connect(); + expect( + element.shadowRoot!.querySelector('.control')?.hasAttribute('size') + ).toBeTrue(); + + await disconnect(); + }); + + it('should set the `spellcheck` attribute on the internal control equal to the value provided', async () => { + const { element, connect, disconnect } = await setup(); + const spellcheck = true; + + element.spellcheck = spellcheck; + + await connect(); + expect( + element + .shadowRoot!.querySelector('.control') + ?.hasAttribute('spellcheck') + ).toBeTrue(); + + await disconnect(); + }); + + it('should initialize to the initial value if no value property is set', async () => { + const { element, connect, disconnect } = await setup(); + + await connect(); + expect(element.value).toEqual(element.initialValue); + + await disconnect(); + }); + + it('should initialize to the provided value attribute if set pre-connection', async () => { + const { element, connect, disconnect } = await setup(); + + element.setAttribute('value', 'foobar'); + await connect(); + + expect(element.value).toEqual('foobar'); + + await disconnect(); + }); + + it('should initialize to the provided value attribute if set post-connection', async () => { + const { element, connect, disconnect } = await setup(); + + await connect(); + element.setAttribute('value', 'foobar'); + + expect(element.value).toEqual('foobar'); + + await disconnect(); + }); + + it('should initialize to the provided value property if set pre-connection', async () => { + const { element, connect, disconnect } = await setup(); + element.value = 'foobar'; + await connect(); + + expect(element.value).toEqual('foobar'); + + await disconnect(); + }); + it('should hide the label when start content is provided', async () => { + const { element, connect, disconnect } = await setup(); + const div: HTMLDivElement = document.createElement( + 'svg' + ) as HTMLDivElement; + div.setAttribute('height', '100px'); + div.setAttribute('width', '100px'); + + await connect(); + div.slot = 'start'; + element.appendChild(div); + + expect( + element.shadowRoot + ?.querySelector('label') + ?.classList.contains('label__hidden') + ).toBeTrue(); + + await disconnect(); + }); + + it('should hide the label when end content is provided', async () => { + const { element, connect, disconnect } = await setup(); + const div: HTMLDivElement = document.createElement( + 'svg' + ) as HTMLDivElement; + div.setAttribute('height', '100px'); + div.setAttribute('width', '100px'); + + await connect(); + div.slot = 'end'; + element.appendChild(div); + + expect( + element.shadowRoot + ?.querySelector('label') + ?.classList.contains('label__hidden') + ).toBeTrue(); + + await disconnect(); + }); + it('should hide the label when start and end content are provided', async () => { + const { element, connect, disconnect } = await setup(); + const div: HTMLDivElement = document.createElement( + 'svg' + ) as HTMLDivElement; + div.setAttribute('height', '100px'); + div.setAttribute('width', '100px'); + + const div2: HTMLDivElement = div; + + await connect(); + div.slot = 'start'; + div2.slot = 'end'; + + element.appendChild(div); + element.appendChild(div2); + + expect( + element.shadowRoot + ?.querySelector('label') + ?.classList.contains('label__hidden') + ).toBeTrue(); + + await disconnect(); + }); + it('should hide the label when whitespace only text nodes are slotted', async () => { + const { element, connect, disconnect } = await setup(); + const whitespace: Node = document.createTextNode(' ') as Node; + const whitespace2: Node = document.createTextNode(' \r ') as Node; + + await connect(); + + element.appendChild(whitespace); + element.appendChild(whitespace2); + + expect( + element.shadowRoot + ?.querySelector('label') + ?.classList.contains('label__hidden') + ).toBeTrue(); + + await disconnect(); + }); + + it('should hide the label when no default slotted content is provided', async () => { + const { element, connect, disconnect } = await setup(); + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('label') + ?.classList.contains('label__hidden') + ).toBeTrue(); + + await disconnect(); + }); + + describe('Delegates ARIA textbox', () => { + it('should set the `aria-atomic` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaAtomic = 'true'; + + element.ariaAtomic = ariaAtomic; + + await connect(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-atomic') + ).toEqual(ariaAtomic); + + await disconnect(); + }); + + it('should set the `aria-busy` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaBusy = 'false'; + + element.ariaBusy = ariaBusy; + + await connect(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-busy') + ).toEqual(ariaBusy); + + await disconnect(); + }); + + it('should set the `aria-controls` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaControls = 'testId'; + + element.ariaControls = ariaControls; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-controls') + ).toEqual(ariaControls); + + await disconnect(); + }); + + it('should set the `aria-current` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaCurrent = 'page'; + + element.ariaCurrent = ariaCurrent; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-current') + ).toEqual(ariaCurrent); + + await disconnect(); + }); + + it('should set the `aria-describedby` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaDescribedby = 'testId'; + + element.ariaDescribedby = ariaDescribedby; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-describedby') + ).toEqual(ariaDescribedby); + + await disconnect(); + }); + + it('should set the `aria-details` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaDetails = 'testId'; + + element.ariaDetails = ariaDetails; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-details') + ).toEqual(ariaDetails); + + await disconnect(); + }); + + it('should set the `aria-disabled` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaDisabled = 'true'; + + element.ariaDisabled = ariaDisabled; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-disabled') + ).toEqual(ariaDisabled); + + await disconnect(); + }); + + it('should set the `aria-errormessage` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaErrormessage = 'test'; + + element.ariaErrormessage = ariaErrormessage; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-errormessage') + ).toEqual(ariaErrormessage); + + await disconnect(); + }); + + it('should set the `aria-flowto` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaFlowto = 'testId'; + + element.ariaFlowto = ariaFlowto; + + await connect(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-flowto') + ).toEqual(ariaFlowto); + + await disconnect(); + }); + + it('should set the `aria-haspopup` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaHaspopup = 'true'; + + element.ariaHaspopup = ariaHaspopup; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-haspopup') + ).toEqual(ariaHaspopup); + + await disconnect(); + }); + + it('should set the `aria-hidden` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaHidden = 'true'; + + element.ariaHidden = ariaHidden; + + await connect(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-hidden') + ).toEqual(ariaHidden); + + await disconnect(); + }); + + it('should set the `aria-invalid` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaInvalid = 'spelling'; + + element.ariaInvalid = ariaInvalid; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-invalid') + ).toEqual(ariaInvalid); + + await disconnect(); + }); + + it('should set the `aria-keyshortcuts` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaKeyshortcuts = 'F4'; + + element.ariaKeyshortcuts = ariaKeyshortcuts; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-keyshortcuts') + ).toEqual(ariaKeyshortcuts); + + await disconnect(); + }); + + it('should set the `aria-label` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaLabel = 'Foo label'; + + element.ariaLabel = ariaLabel; + + await connect(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-label') + ).toEqual(ariaLabel); + + await disconnect(); + }); + + it('should set the `aria-labelledby` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaLabelledby = 'testId'; + + element.ariaLabelledby = ariaLabelledby; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-labelledby') + ).toEqual(ariaLabelledby); + + await disconnect(); + }); + + it('should set the `aria-live` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaLive = 'polite'; + + element.ariaLive = ariaLive; + + await connect(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-live') + ).toEqual(ariaLive); + + await disconnect(); + }); + + it('should set the `aria-owns` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaOwns = 'testId'; + + element.ariaOwns = ariaOwns; + + await connect(); + + expect( + element + .shadowRoot!.querySelector('.control') + ?.getAttribute('aria-owns') + ).toEqual(ariaOwns); + + await disconnect(); + }); + + it('should set the `aria-relevant` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaRelevant = 'removals'; + + element.ariaRelevant = ariaRelevant; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-relevant') + ).toEqual(ariaRelevant); + + await disconnect(); + }); + + it('should set the `aria-roledescription` attribute on the internal control when provided', async () => { + const { element, connect, disconnect } = await setup(); + const ariaRoledescription = 'slide'; + + element.ariaRoledescription = ariaRoledescription; + + await connect(); + + expect( + element.shadowRoot + ?.querySelector('.control') + ?.getAttribute('aria-roledescription') + ).toEqual(ariaRoledescription); + + await disconnect(); + }); + }); + + describe('events', () => { + it('should fire a change event the internal control emits a change event', async () => { + const { element, connect, disconnect } = await setup(); + const event = new Event('change', { + key: 'a' + } as KeyboardEventInit); + let wasChanged = false; + + await connect(); + + element.addEventListener('change', e => { + e.preventDefault(); + + wasChanged = true; + }); + + const textarea = element.shadowRoot!.querySelector('input'); + textarea?.dispatchEvent(event); + + expect(wasChanged).toBeTrue(); + + await disconnect(); + }); + }); + + describe('with constraint validation', () => { + Object.keys(TextFieldType) + .map( + (key): TextFieldType => TextFieldType[key as keyof typeof TextFieldType] + ) + .forEach(type => { + describe(`of [type="${type}"]`, () => { + describe('that is [required]', () => { + it("should be invalid when it's value property is an empty string", async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + + element.type = type; + element.required = true; + element.value = ''; + + expect(element.validity.valueMissing).toBeTrue(); + + await disconnect(); + }); + + it('should be valid when value property is a string that is non-empty', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + + element.type = type; + + element.required = true; + element.value = 'some value'; + + expect(element.validity.valueMissing).toBeFalse(); + await disconnect(); + }); + }); + describe('that has a [minlength] attribute', () => { + it('should be valid if the value is an empty string', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + const value = ''; + element.type = type; + element.value = value; + element.minlength = value.length + 1; + + expect(element.validity.tooShort).toBeFalse(); + await disconnect(); + }); + it('should be valid if the value has a length less than the minlength', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + const value = 'value'; + element.type = type; + element.value = value; + element.minlength = value.length + 1; + + expect(element.validity.tooShort).toBeFalse(); + await disconnect(); + }); + }); + + describe('that has a [maxlength] attribute', () => { + it('should be valid if the value is an empty string', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + + const value = ''; + element.type = type; + element.value = value; + element.maxlength = value.length; + + expect(element.validity.tooLong).toBeFalse(); + + await disconnect(); + }); + it('should be valid if the value has a exceeding the maxlength', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + const value = 'value'; + element.type = type; + element.value = value; + element.maxlength = value.length - 1; + + expect(element.validity.tooLong).toBeFalse(); + await disconnect(); + }); + it('should be valid if the value has a length shorter than maxlength and the element is [required]', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + const value = 'value'; + element.type = type; + element.required = true; + element.value = value; + element.maxlength = value.length + 1; + + expect(element.validity.tooLong).toBeFalse(); + await disconnect(); + }); + }); + + describe('that has a [pattern] attribute', () => { + it('should be valid if the value matches a pattern', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + const value = 'value'; + element.type = type; + element.required = true; + element.pattern = value; + element.value = value; + + expect( + element.validity.patternMismatch + ).toBeFalse(); + await disconnect(); + }); + + it('should be invalid if the value does not match a pattern', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + const value = 'value'; + element.type = type; + element.required = true; + element.pattern = value; + element.value = 'foo'; + + expect(element.validity.patternMismatch).toBeTrue(); + await disconnect(); + }); + }); + }); + }); + describe('of [type="email"]', () => { + it('should be valid when value is an empty string', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + element.type = TextFieldType.email; + element.required = true; + element.value = ''; + + expect(element.validity.typeMismatch).toBeFalse(); + + await disconnect(); + }); + it('should be a typeMismatch when value is not a valid email', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + element.type = TextFieldType.email; + element.required = true; + element.value = 'foobar'; + + expect(element.validity.typeMismatch).toBeTrue(); + + await disconnect(); + }); + }); + describe('of [type="url"]', () => { + it('should be valid when value is an empty string', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + element.type = TextFieldType.url; + element.required = true; + element.value = ''; + + expect(element.validity.typeMismatch).toBeFalse(); + + await disconnect(); + }); + it('should be a typeMismatch when value is not a valid URL', async () => { + const { element, connect, disconnect } = await setup(); + await connect(); + element.type = TextFieldType.url; + element.required = true; + element.value = 'foobar'; + + expect(element.validity.typeMismatch).toBeTrue(); + + await disconnect(); + }); + }); + }); + + describe("when the owning form's reset() method is invoked", () => { + it("should reset it's value property to an empty string if no value attribute is set", async () => { + const { element, connect, disconnect, parent } = await setup(); + + const form = document.createElement('form'); + form.appendChild(element); + parent.appendChild(form); + + await connect(); + + element.value = 'test-value'; + + expect(element.getAttribute('value')).toBeNull(); + expect(element.value).toBe('test-value'); + + form.reset(); + + expect(element.value).toBe(''); + + await disconnect(); + }); + + it("should reset it's value property to the value of the value attribute if it is set", async () => { + const { element, connect, disconnect, parent } = await setup(); + + const form = document.createElement('form'); + form.appendChild(element); + parent.appendChild(form); + await connect(); + + element.setAttribute('value', 'attr-value'); + + element.value = 'test-value'; + + expect(element.getAttribute('value')).toEqual('attr-value'); + + expect(element.value).toEqual('test-value'); + + form.reset(); + + expect(element.value).toEqual('attr-value'); + + await disconnect(); + }); + + it('should put the control into a clean state, where value attribute changes change the property value prior to user or programmatic interaction', async () => { + const { element, connect, disconnect, parent } = await setup(); + const form = document.createElement('form'); + form.appendChild(element); + parent.appendChild(form); + + await connect(); + + element.value = 'test-value'; + element.setAttribute('value', 'attr-value'); + + expect(element.value).toEqual('test-value'); + + form.reset(); + + expect(element.value).toEqual('attr-value'); + + element.setAttribute('value', 'new-attr-value'); + + expect(element.value).toEqual('new-attr-value'); + await disconnect(); + }); + }); +}); diff --git a/packages/storybook/.storybook/preview.js b/packages/storybook/.storybook/preview.js index d34312cb9b..a7451dfb16 100644 --- a/packages/storybook/.storybook/preview.js +++ b/packages/storybook/.storybook/preview.js @@ -105,5 +105,5 @@ configureActions({ depth: 1 }); -// Update the GUID on this line to trigger a turbosnap full rebuild: d3f8a1b2-4c5d-4e7f-8a9e-1b2c3d4e5f6a +// Update the GUID on this line to trigger a turbosnap full rebuild: c83eb995-9b50-4d5a-af33-254cd68f86ff // See https://www.chromatic.com/docs/turbosnap/#full-rebuilds