diff --git a/change/@fluentui-web-components-7441a75d-cb13-41f1-a772-07b6ea6d14d4.json b/change/@fluentui-web-components-7441a75d-cb13-41f1-a772-07b6ea6d14d4.json new file mode 100644 index 00000000000000..e3d0387fd61263 --- /dev/null +++ b/change/@fluentui-web-components-7441a75d-cb13-41f1-a772-07b6ea6d14d4.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Fix `spellcheck` conversion and propagation for TextInput and TextArea.", + "packageName": "@fluentui/web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/src/text-input/text-input.base.ts b/packages/web-components/src/text-input/text-input.base.ts index 4a2f08be10eb9c..b68106116930ab 100644 --- a/packages/web-components/src/text-input/text-input.base.ts +++ b/packages/web-components/src/text-input/text-input.base.ts @@ -270,8 +270,8 @@ export class BaseTextInput extends FASTElement { */ @attr({ converter: { - fromView: value => (typeof value === 'string' ? ['true', ''].includes(value.trim().toLowerCase()) : null), - toView: value => value.toString(), + fromView: value => (typeof value === 'string' ? ['true', ''].includes(value.trim().toLowerCase()) : value), + toView: value => (value === null || value === undefined ? null : value.toString()), }, }) public spellcheck!: boolean; diff --git a/packages/web-components/src/text-input/text-input.spec.ts b/packages/web-components/src/text-input/text-input.spec.ts index 190f4966a94651..e72265b92caaf4 100644 --- a/packages/web-components/src/text-input/text-input.spec.ts +++ b/packages/web-components/src/text-input/text-input.spec.ts @@ -61,13 +61,101 @@ test.describe('TextInput', () => { await expect(control).toHaveAttribute('required'); }); - test('should set the `spellcheck` attribute on the internal control', async ({ fastPage }) => { - const { element } = fastPage; - const control = element.locator('input'); + test.describe('spellcheck', () => { + const attributeCases = [ + { value: 'true', expected: true }, + { value: 'false', expected: false }, + { value: '', expected: true }, + { value: 'anything', expected: false }, + ] as const; + + for (const { value, expected } of attributeCases) { + test(`should propagate \`spellcheck="${value}"\` to the internal control`, async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('input'); + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName}> +
+ `); + + await element.evaluate((node, attributeValue) => { + node.setAttribute('spellcheck', attributeValue); + }, value); + + await expect(element).toHaveAttribute('spellcheck', value); + await expect(element).toHaveJSProperty('spellcheck', expected); + await expect(control).toHaveAttribute('spellcheck', expected.toString()); + await expect(control).toHaveJSProperty('spellcheck', expected); + }); + } + + for (const spellcheck of [true, false]) { + test(`should propagate a ${spellcheck} \`spellcheck\` property to the internal control`, async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('input'); - await fastPage.setTemplate({ attributes: { spellcheck: 'true' } }); + await fastPage.setTemplate(); + + await element.evaluate((node: TextInput, value) => { + node.spellcheck = value; + }, spellcheck); + + await expect(element).toHaveAttribute('spellcheck', spellcheck.toString()); + await expect(element).toHaveJSProperty('spellcheck', spellcheck); + await expect(control).toHaveAttribute('spellcheck', spellcheck.toString()); + await expect(control).toHaveJSProperty('spellcheck', spellcheck); + }); + } - await expect(control).toHaveAttribute('spellcheck', 'true'); + for (const value of ['null', 'undefined'] as const) { + test(`should propagate a ${value} \`spellcheck\` property to the internal control`, async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('input'); + + await fastPage.setTemplate(); + + await element.evaluate((node, nullishValue) => { + Reflect.set(node, 'spellcheck', nullishValue === 'null' ? null : undefined); + }, value); + + await expect(element).not.toHaveAttribute('spellcheck'); + await expect(element).toHaveJSProperty('spellcheck', value === 'null' ? null : undefined); + await expect(control).not.toHaveAttribute('spellcheck'); + }); + } + + test('should propagate removal of the host `spellcheck` attribute to the internal control', async ({ + fastPage, + }) => { + const { element } = fastPage; + const control = element.locator('input'); + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} spellcheck="true"> +
+ `); + + await element.evaluate(node => { + node.removeAttribute('spellcheck'); + }); + + await expect(element).not.toHaveAttribute('spellcheck'); + await expect(element).toHaveJSProperty('spellcheck', null); + await expect(control).not.toHaveAttribute('spellcheck'); + }); + + test('should use the browser default when `spellcheck` is unset', async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('input'); + + await fastPage.setTemplate(); + + await expect(element).not.toHaveAttribute('spellcheck'); + await expect(control).not.toHaveAttribute('spellcheck'); + }); }); test('should set the `maxlength` attribute on the internal control', async ({ fastPage }) => { diff --git a/packages/web-components/src/textarea/textarea.base.ts b/packages/web-components/src/textarea/textarea.base.ts index e0cbb42deb7e7c..0e264e415d58bc 100644 --- a/packages/web-components/src/textarea/textarea.base.ts +++ b/packages/web-components/src/textarea/textarea.base.ts @@ -61,6 +61,8 @@ export class BaseTextArea extends FASTElement { * @internal */ protected controlElChanged() { + this.controlEl.spellcheck = this.spellcheck; + this.controlElAttrObserver = new MutationObserver(() => { this.setValidity(); }); @@ -316,7 +318,12 @@ export class BaseTextArea extends FASTElement { * @remarks * HTML Attribute: `spellcheck` */ - @attr({ mode: 'boolean' }) + @attr({ + converter: { + fromView: value => (typeof value === 'string' ? ['true', ''].includes(value.trim().toLowerCase()) : value), + toView: value => (value === null || value === undefined ? null : value.toString()), + }, + }) public spellcheck = false; /** diff --git a/packages/web-components/src/textarea/textarea.spec.ts b/packages/web-components/src/textarea/textarea.spec.ts index 30e41542c091bb..e92a9de7879824 100644 --- a/packages/web-components/src/textarea/textarea.spec.ts +++ b/packages/web-components/src/textarea/textarea.spec.ts @@ -51,7 +51,6 @@ test.describe('TextArea', () => { required: true, disabled: true, readonly: true, - spellcheck: true, autocomplete: 'on', maxlength: '100', minlength: '10', @@ -62,13 +61,111 @@ test.describe('TextArea', () => { await expect(control).toHaveJSProperty('required', true); await expect(control).toHaveJSProperty('disabled', true); await expect(control).toHaveJSProperty('readOnly', true); - await expect(control).toHaveJSProperty('spellcheck', true); await expect(control).toHaveJSProperty('autocomplete', 'on'); await expect(control).toHaveJSProperty('maxLength', 100); await expect(control).toHaveJSProperty('minLength', 10); await expect(control).toHaveJSProperty('placeholder', 'Placeholder'); }); + test.describe('spellcheck', () => { + const attributeCases = [ + { value: 'true', expected: true }, + { value: 'false', expected: false }, + { value: '', expected: true }, + { value: 'anything', expected: false }, + ] as const; + + for (const { value, expected } of attributeCases) { + test(`should propagate \`spellcheck="${value}"\` to the internal control`, async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('textarea'); + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName}> +
+ `); + + await element.evaluate((node, attributeValue) => { + node.setAttribute('spellcheck', attributeValue); + }, value); + + await expect(element).toHaveAttribute('spellcheck', value); + await expect(element).toHaveJSProperty('spellcheck', expected); + await expect(control).toHaveAttribute('spellcheck', expected.toString()); + await expect(control).toHaveJSProperty('spellcheck', expected); + }); + } + + for (const spellcheck of [true, false]) { + test(`should propagate a ${spellcheck} \`spellcheck\` property to the internal control`, async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('textarea'); + + await fastPage.setTemplate(); + + await element.evaluate((node: HTMLElement, value) => { + node.spellcheck = value; + }, spellcheck); + + await expect(element).toHaveAttribute('spellcheck', spellcheck.toString()); + await expect(element).toHaveJSProperty('spellcheck', spellcheck); + await expect(control).toHaveAttribute('spellcheck', spellcheck.toString()); + await expect(control).toHaveJSProperty('spellcheck', spellcheck); + }); + } + + for (const value of ['null', 'undefined'] as const) { + test(`should propagate a ${value} \`spellcheck\` property to the internal control`, async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('textarea'); + + await fastPage.setTemplate(); + + await element.evaluate((node, nullishValue) => { + Reflect.set(node, 'spellcheck', nullishValue === 'null' ? null : undefined); + }, value); + + await expect(element).not.toHaveAttribute('spellcheck'); + await expect(element).toHaveJSProperty('spellcheck', value === 'null' ? null : undefined); + await expect(control).not.toHaveAttribute('spellcheck'); + }); + } + + test('should propagate removal of the host `spellcheck` attribute to the internal control', async ({ + fastPage, + }) => { + const { element } = fastPage; + const control = element.locator('textarea'); + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} spellcheck="true"> +
+ `); + + await element.evaluate(node => { + node.removeAttribute('spellcheck'); + }); + + await expect(element).not.toHaveAttribute('spellcheck'); + await expect(element).toHaveJSProperty('spellcheck', null); + await expect(control).not.toHaveAttribute('spellcheck'); + }); + + test('should use the browser default when `spellcheck` is unset', async ({ fastPage }) => { + const { element } = fastPage; + const control = element.locator('textarea'); + + await fastPage.setTemplate(); + + await expect(element).toHaveAttribute('spellcheck', 'false'); + await expect(element).toHaveJSProperty('spellcheck', false); + await expect(control).toHaveAttribute('spellcheck', 'false'); + await expect(control).toHaveJSProperty('spellcheck', false); + }); + }); + test('should be associated with the given external labels', async ({ fastPage, page }) => { const { element } = fastPage; diff --git a/packages/web-components/src/textarea/textarea.stories.ts b/packages/web-components/src/textarea/textarea.stories.ts index ab90fef6d4a1a2..adfb5085a7d721 100644 --- a/packages/web-components/src/textarea/textarea.stories.ts +++ b/packages/web-components/src/textarea/textarea.stories.ts @@ -25,7 +25,7 @@ const storyTemplate = html>` ?readonly="${x => x.readOnly}" ?required="${x => x.required}" size="${x => x.size}" - ?spellcheck="${x => x.spellcheck}" + spellcheck="${x => x.spellcheck}" resize="${x => x.resize}" value="${x => x.value}" > diff --git a/packages/web-components/src/textarea/textarea.template.html b/packages/web-components/src/textarea/textarea.template.html index 83216b4cecdf6e..5c8f55c73d546e 100644 --- a/packages/web-components/src/textarea/textarea.template.html +++ b/packages/web-components/src/textarea/textarea.template.html @@ -13,7 +13,7 @@ ?required="{{required}}" ?disabled="{{disabled}}" ?readonly="{{readOnly}}" - ?spellcheck="{{spellcheck}}" + spellcheck="{{spellcheck}}" autocomplete="{{autocomplete}}" maxlength="{{maxLength}}" minlength="{{minLength}}" diff --git a/packages/web-components/src/textarea/textarea.template.ts b/packages/web-components/src/textarea/textarea.template.ts index f8bf158c9912cc..cc97b3a038aa14 100644 --- a/packages/web-components/src/textarea/textarea.template.ts +++ b/packages/web-components/src/textarea/textarea.template.ts @@ -21,7 +21,7 @@ export function textAreaTemplate(): ElementViewTemplate { ?required="${x => x.required}" ?disabled="${x => x.disabled}" ?readonly="${x => x.readOnly}" - ?spellcheck="${x => x.spellcheck}" + spellcheck="${x => x.spellcheck}" autocomplete="${x => x.autocomplete}" maxlength="${x => x.maxLength}" minlength="${x => x.minLength}"