From a9b6efdebdc9768c2292a74fe4197437b7016459 Mon Sep 17 00:00:00 2001 From: Richard Helm Date: Thu, 14 Nov 2024 15:51:21 +0000 Subject: [PATCH] Decouple text-field from FAST foundation --- libs/components/src/lib/text-field/README.md | 1 + .../text-field/text-field.form-associated.ts | 9 + .../src/lib/text-field/text-field.spec.ts | 27 ++ .../src/lib/text-field/text-field.ts | 295 +++++++++++++++++- .../generator/customElementDeclarations.ts | 11 +- 5 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 libs/components/src/lib/text-field/text-field.form-associated.ts diff --git a/libs/components/src/lib/text-field/README.md b/libs/components/src/lib/text-field/README.md index 27f24b57ee..9426acbfd6 100644 --- a/libs/components/src/lib/text-field/README.md +++ b/libs/components/src/lib/text-field/README.md @@ -223,5 +223,6 @@ The `helper-text` slot allows you to use rich content as the text-field's helper | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `checkValidity` | Returns `true` if the element's `value` passes validity checks; otherwise, returns `false` and fires an `invalid` event at the element. | | `reportValidity` | Returns `true` if the element's `value` passes validity checks; otherwise, returns `false`, fires an `invalid` event at the element, and (if the event isn't canceled) reports the problem to the user. | +| `select` | Selects all the text in the text field | diff --git a/libs/components/src/lib/text-field/text-field.form-associated.ts b/libs/components/src/lib/text-field/text-field.form-associated.ts new file mode 100644 index 0000000000..9f7dfd65ac --- /dev/null +++ b/libs/components/src/lib/text-field/text-field.form-associated.ts @@ -0,0 +1,9 @@ +import { FormAssociated, FoundationElement } from '@microsoft/fast-foundation'; + +class _TextField extends FoundationElement {} +// eslint-disable-next-line @typescript-eslint/naming-convention +interface _TextField extends FormAssociated {} + +export class FormAssociatedTextField extends FormAssociated(_TextField) { + proxy = document.createElement('input'); +} diff --git a/libs/components/src/lib/text-field/text-field.spec.ts b/libs/components/src/lib/text-field/text-field.spec.ts index 5214d5324c..7078b71c49 100644 --- a/libs/components/src/lib/text-field/text-field.spec.ts +++ b/libs/components/src/lib/text-field/text-field.spec.ts @@ -192,6 +192,15 @@ describe('vwc-text-field', () => { await elementUpdated(element); expect(getInput()?.hasAttribute('autofocus')).toEqual(true); }); + + it('should focus the input element when connected', async () => { + element = (await fixture( + `<${COMPONENT_TAG_NAME} autofocus>` + )) as TextField; + await elementUpdated(element); + + expect(document.activeElement).toEqual(getInput()); + }); }); describe('inputmode', function () { @@ -238,6 +247,14 @@ describe('vwc-text-field', () => { }); }); + describe('spellcheck', function () { + it('should set spellcheck attribute on the input', async function () { + element.spellcheck = true; + await elementUpdated(element); + expect(getInput()?.hasAttribute('spellcheck')).toBe(true); + }); + }); + describe('maxlength', function () { const value = '8'; const propertyName = 'maxlength'; @@ -634,6 +651,16 @@ describe('vwc-text-field', () => { }); }); + describe('select method', function () { + it('should call select on the input', async function () { + getInput().select = jest.fn(); + + element.select(); + + expect(getInput().select).toHaveBeenCalled(); + }); + }); + describe('accessible helper text', function () { function getAccessibleDescription() { const describedBy = element diff --git a/libs/components/src/lib/text-field/text-field.ts b/libs/components/src/lib/text-field/text-field.ts index cf56932828..1ce4b1ce26 100644 --- a/libs/components/src/lib/text-field/text-field.ts +++ b/libs/components/src/lib/text-field/text-field.ts @@ -1,6 +1,11 @@ -import { TextField as FoundationTextfield } from '@microsoft/fast-foundation'; -import { attr, observable } from '@microsoft/fast-element'; +import { + attr, + DOM, + nullableNumberConverter, + observable, +} from '@microsoft/fast-element'; import { memoizeWith } from 'ramda'; +import { applyMixins } from '@microsoft/fast-foundation'; import type { Appearance, Shape, Size } from '../enums'; import { AffixIcon, @@ -15,6 +20,8 @@ import { import { generateRandomId } from '../../shared/utils/randomId'; import { Reflector } from '../../shared/utils/Reflector'; import { applyMixinsWithObservables } from '../../shared/utils/applyMixinsWithObservables'; +import { ARIAGlobalStatesAndProperties } from '../../shared/foundation/patterns'; +import { FormAssociatedTextField } from './text-field.form-associated'; export type TextFieldAppearance = Extract< Appearance, @@ -24,6 +31,43 @@ export type TextFieldShape = Extract; export type TextFieldSize = Extract; +/** + * Text field sub-types + * @public + */ +export const TextFieldType = { + /** + * An email TextField + */ + email: 'email', + + /** + * A password TextField + */ + password: 'password', + + /** + * A telephone TextField + */ + tel: 'tel', + + /** + * A text TextField + */ + text: 'text', + + /** + * A URL TextField + */ + url: 'url', +} as const; + +/** + * Types for the text field sub-types + * @public + */ +export type TextFieldType = typeof TextFieldType[keyof typeof TextFieldType]; + // Safari does not support styling the `::placeholder` pseudo-element on slotted input // See bug: https://bugs.webkit.org/show_bug.cgi?id=223814 // As a workaround we add a stylesheet to root of text-field to apply the styles @@ -81,7 +125,227 @@ const installSafariWorkaroundStyle = (forElement: TextField) => { */ @errorText @formElements -export class TextField extends FoundationTextfield { +export class TextField extends FormAssociatedTextField { + /** + * When true, the control will be immutable by user interaction. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly | readonly HTML attribute} for more information. + * @public + * @remarks + * HTML Attribute: readonly + */ + @attr({ attribute: 'readonly', mode: 'boolean' }) + readOnly!: boolean; + /** + * @internal + */ + readOnlyChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.readOnly = this.readOnly; + this.validate(); + } + } + + /** + * Indicates that this element should get focus after the page finishes loading. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautofocus | autofocus HTML attribute} for more information. + * @public + * @remarks + * HTML Attribute: autofocus + */ + @attr({ mode: 'boolean' }) + override autofocus!: boolean; + /** + * @internal + */ + autofocusChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.autofocus = this.autofocus; + this.validate(); + } + } + + /** + * Sets the placeholder value of the element, generally used to provide a hint to the user. + * @public + * @remarks + * HTML Attribute: placeholder + * Using this attribute does is not a valid substitute for a labeling element. + */ + @attr + placeholder!: string; + /** + * @internal + */ + placeholderChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.placeholder = this.placeholder; + } + } + + /** + * Allows setting a type or mode of text. + * @public + * @remarks + * HTML Attribute: type + */ + @attr + // eslint-disable-next-line @nrwl/nx/workspace/no-attribute-default-value + type: TextFieldType = TextFieldType.text; + /** + * @internal + */ + typeChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.type = this.type; + this.validate(); + } + } + + /** + * Allows associating a {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/datalist | datalist} to the element by {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/id}. + * @public + * @remarks + * HTML Attribute: list + */ + @attr + list!: string; + /** + * @internal + */ + listChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.setAttribute('list', this.list); + this.validate(); + } + } + + /** + * The maximum number of characters a user can enter. + * @public + * @remarks + * HTMLAttribute: maxlength + */ + @attr({ converter: nullableNumberConverter }) + maxlength!: number; + /** + * @internal + */ + maxlengthChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.maxLength = this.maxlength; + this.validate(); + } + } + + /** + * The minimum number of characters a user can enter. + * @public + * @remarks + * HTMLAttribute: minlength + */ + @attr({ converter: nullableNumberConverter }) + minlength!: number; + /** + * @internal + */ + minlengthChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.minLength = this.minlength; + this.validate(); + } + } + + /** + * A regular expression that the value must match to pass validation. + * @public + * @remarks + * HTMLAttribute: pattern + */ + @attr + pattern!: string; + /** + * @internal + */ + patternChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.pattern = this.pattern; + this.validate(); + } + } + + /** + * Sets the width of the element to a specified number of characters. + * @public + * @remarks + * HTMLAttribute: size + */ + @attr({ converter: nullableNumberConverter }) + size!: number; + /** + * @internal + */ + sizeChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.size = this.size; + } + } + + /** + * Controls whether or not to enable spell checking for the input field, or if the default spell checking configuration should be used. + * @public + * @remarks + * HTMLAttribute: spellcheck + */ + @attr({ mode: 'boolean' }) + override spellcheck!: boolean; + /** + * @internal + */ + spellcheckChanged(): void { + if (this.proxy instanceof HTMLInputElement) { + this.proxy.spellcheck = this.spellcheck; + } + } + + /** + * A reference to the internal input element + * @internal + */ + control!: HTMLInputElement; + + /** + * Selects all the text in the text field + * + * @public + */ + select() { + this.control.select(); + } + + /** + * Handles the internal control's `input` event + * @internal + */ + handleTextInput(): void { + this.value = this.control.value; + } + + /** + * Change event handler for inner control. + * @remarks + * "Change" events are not `composable` so they will not + * permeate the shadow DOM boundary. This fn effectively proxies + * the change event, emitting a `change` event whenever the internal + * control emits a `change` event + * @internal + */ + handleChange(): void { + this.$emit('change'); + } + + /** {@inheritDoc (FormAssociated:interface).validate} */ + override validate() { + super.validate(this.control); + } + @attr appearance?: TextFieldAppearance; @attr shape?: TextFieldShape; @attr autoComplete?: string; @@ -149,6 +413,15 @@ export class TextField extends FoundationTextfield { override connectedCallback() { super.connectedCallback(); + this.proxy.setAttribute('type', this.type); + this.validate(); + + if (this.autofocus) { + DOM.queueUpdate(() => { + this.focus(); + }); + } + if (!this.control) { // Input and label must be created outside the shadow dom to support autofill from some password managers. @@ -304,17 +577,29 @@ export class TextField extends FoundationTextfield { } } +/** + * Includes ARIA states and properties relating to the ARIA textbox role + * + * @public + */ +export class DelegatesARIATextbox {} + +export interface DelegatesARIATextbox extends ARIAGlobalStatesAndProperties {} +applyMixins(DelegatesARIATextbox, ARIAGlobalStatesAndProperties); + export interface TextField extends AffixIcon, ErrorText, FormElement, FormElementCharCount, FormElementHelperText, - FormElementSuccessText {} + FormElementSuccessText, + DelegatesARIATextbox {} applyMixinsWithObservables( TextField, AffixIcon, FormElementCharCount, FormElementHelperText, - FormElementSuccessText + FormElementSuccessText, + DelegatesARIATextbox ); diff --git a/libs/wrapper-gen/src/generator/customElementDeclarations.ts b/libs/wrapper-gen/src/generator/customElementDeclarations.ts index 4d85d55ae4..c12249b64e 100644 --- a/libs/wrapper-gen/src/generator/customElementDeclarations.ts +++ b/libs/wrapper-gen/src/generator/customElementDeclarations.ts @@ -453,13 +453,14 @@ const VividMixins: Record = { ], TrappedFocus: [], DelegatesARIATextbox: [], + ARIAGlobalStatesAndProperties: [], }; /** * Returns that mixins that a component uses. */ export const extractVividMixins = ( - componentName: string, + className: string, modulePath: string ): string[] => { const src = fs.readFileSync(getTypescriptDefinitionPath(modulePath), 'utf8'); @@ -468,9 +469,11 @@ export const extractVividMixins = ( for (const line of lines) { // Find the line declaring the mixins looking like this: // export interface ComponentName extends MixinA, MixinB {} - const match = line.match(/export interface (\w+) extends (.*) {/); + const match = line.match( + new RegExp(`export interface ${className} extends (.*) {`) + ); if (match) { - return match[2].split(',').map((m) => m.trim()); + return match[1].split(',').map((m) => m.trim()); } } @@ -497,7 +500,7 @@ export const getVividComponentDeclaration = ( const declaration = resolveComponentDeclaration(vividDeclarations, className); // Apply vivid mixins - const mixins = extractVividMixins(name, declaration._modulePath); + const mixins = extractVividMixins(className, declaration._modulePath); for (const mixinName of mixins) { if (!(mixinName in VividMixins)) { throw new Error(`Unknown mixin ${mixinName}`);