diff --git a/CHANGELOG.md b/CHANGELOG.md index 275729ec8f4e530026275b9f807b2ca31af883ae..9761a188f31890b74cce2547802cd71887247f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Angular Formly**: add two new formly props to `WcsFormlyInputProps` => `maxDate` and `minDate` for date input type - **core**: add bundle and composite-elements output targets in wcs-core package with documentation and examples - **com-nav**: default style for slotted app name anchor link +- **radio**: add `required` prop +- **switch**: add `required` prop ### Changed diff --git a/angular/projects/wcs-angular/src/lib/proxies.ts b/angular/projects/wcs-angular/src/lib/proxies.ts index 8dac08415d02dbddd2996d513b916b94fd5ee816..042028a6671ecb1ae0e0927614ac0145f9fb95b9 100644 --- a/angular/projects/wcs-angular/src/lib/proxies.ts +++ b/angular/projects/wcs-angular/src/lib/proxies.ts @@ -1468,7 +1468,7 @@ export declare interface WcsProgressRadial extends Components.WcsProgressRadial @ProxyCmp({ - inputs: ['disabled', 'label', 'value'], + inputs: ['disabled', 'label', 'required', 'value'], methods: ['setAriaAttribute'] }) @Component({ @@ -1476,7 +1476,7 @@ export declare interface WcsProgressRadial extends Components.WcsProgressRadial changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['disabled', 'label', 'value'], + inputs: ['disabled', 'label', 'required', 'value'], }) export class WcsRadio { protected el: HTMLElement; @@ -1698,7 +1698,7 @@ export declare interface WcsSpinner extends Components.WcsSpinner {} @ProxyCmp({ - inputs: ['checked', 'disabled', 'labelAlignment', 'name'], + inputs: ['checked', 'disabled', 'labelAlignment', 'name', 'required'], methods: ['setAriaAttribute', 'getLabel'] }) @Component({ @@ -1706,7 +1706,7 @@ export declare interface WcsSpinner extends Components.WcsSpinner {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['checked', 'disabled', 'labelAlignment', 'name'], + inputs: ['checked', 'disabled', 'labelAlignment', 'name', 'required'], }) export class WcsSwitch { protected el: HTMLElement; diff --git a/custom-elements.json b/custom-elements.json index d6e263ad9dc7b9a3f89454931e5c391494d240dc..5a253c01cbca53eedcfa1fe3c2a3ee25ab28b1ed 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -13453,193 +13453,6 @@ } ] }, - { - "kind": "javascript-module", - "path": "components/progress-radial/progress-radial.tsx", - "declarations": [ - { - "kind": "class", - "description": "The progress-radial component is a circular progress bar that indicates the current completion of a task. \r\n\r\n## Accessibility guidelines 💡\r\n> Aria attributes and how to display the progress-radial depend on the use case in your application :\r\n>\r\n> - **Case 1 : decorative**\r\n> If the progress-radial is used as a decoration _(if removed, the user doesn't lose any relevant information)_ or in the\r\n> context of another component _(such as progress-radial in a card)_ => **you don't need to show the label nor add an aria-label**.\r\n>\r\n> - **Case 2 : informative**\r\n> If the progress-radial is used to convey important information _(e.g., form completion status, dashboard KPI)_, you need to :\r\n> - **Provide a visible label** that describes the purpose of the progress-radial.\r\n> - **Set the `showLabel` property to `true`** to show the percentage inside the progress-radial.\r\n> - Optionally, use aria-label to provide an accessible name if a visible label is not present.", - "name": "ProgressRadial", - "cssProperties": [ - { - "description": "The width of the line that represents the rail of the progress radial", - "name": "--wcs-progress-radial-rail-width" - }, - { - "description": "The space between each rail of the progress radial", - "name": "--wcs-progress-radial-rail-spacing" - }, - { - "description": "The color of the rail of the progress radial", - "name": "--wcs-progress-radial-rail-color" - }, - { - "description": "The background color of the bar on top of the rail", - "name": "--wcs-progress-radial-value-background-color" - }, - { - "description": "The color of the label inside the progress radial", - "name": "--wcs-progress-radial-label-color" - }, - { - "description": "The font size of the label inside the progress radial", - "name": "--wcs-progress-radial-label-font-size" - }, - { - "description": "The font weight of the label inside the progress radial", - "name": "--wcs-progress-radial-label-font-weight" - }, - { - "description": "The font size of the percentage inside the progress radial", - "name": "--wcs-progress-radial-label-percentage-font-size" - }, - { - "description": "The duration of the animation of the progress radial", - "name": "--wcs-progress-radial-animation-duration" - } - ], - "members": [ - { - "kind": "field", - "name": "el", - "type": { - "text": "HTMLWcsProgressRadialElement" - }, - "privacy": "private" - }, - { - "kind": "field", - "name": "nativeProgress", - "type": { - "text": "HTMLDivElement" - }, - "privacy": "private" - }, - { - "kind": "field", - "name": "inheritedAttributes", - "type": { - "text": "{ [k: string]: any }" - }, - "privacy": "private", - "default": "{}" - }, - { - "kind": "field", - "name": "backgroundImageSize", - "type": { - "text": "number" - }, - "privacy": "private", - "default": "120", - "description": "The initial background image size (120x120) as specified in the background-image css property of .progress-circle" - }, - { - "kind": "field", - "name": "size", - "type": { - "text": "number" - }, - "default": "120", - "description": "The size of the progress radial (in px)" - }, - { - "kind": "field", - "name": "showLabel", - "type": { - "text": "boolean" - }, - "default": "false", - "description": "Whether the component should display the % label inside" - }, - { - "kind": "field", - "name": "value", - "type": { - "text": "number" - }, - "default": "0", - "description": "The value of the progress radial. Prefer values between 0 and 100." - }, - { - "kind": "method", - "name": "setAriaAttribute", - "parameters": [ - { - "name": "attr", - "type": { - "text": "AriaAttributeName" - } - }, - { - "name": "value", - "type": { - "text": "string | null | undefined" - } - } - ] - }, - { - "kind": "method", - "name": "render" - }, - { - "kind": "method", - "name": "getSvgStyle" - }, - { - "kind": "method", - "name": "getSize" - } - ], - "attributes": [ - { - "name": "size", - "fieldName": "size", - "type": { - "text": "number" - } - }, - { - "name": "show-label", - "fieldName": "showLabel", - "type": { - "text": "boolean" - } - }, - { - "name": "value", - "fieldName": "value", - "type": { - "text": "number" - } - } - ], - "tagName": "wcs-progress-radial", - "events": [], - "customElement": true - } - ], - "exports": [ - { - "kind": "js", - "name": "ProgressRadial", - "declaration": { - "name": "ProgressRadial", - "module": "components/progress-radial/progress-radial.tsx" - } - }, - { - "kind": "custom-element-definition", - "name": "wcs-progress-radial", - "declaration": { - "name": "ProgressRadial", - "module": "components/progress-radial/progress-radial.tsx" - } - } - ] - }, { "kind": "javascript-module", "path": "components/radio/radio-interface.ts", @@ -14086,6 +13899,193 @@ } ] }, + { + "kind": "javascript-module", + "path": "components/progress-radial/progress-radial.tsx", + "declarations": [ + { + "kind": "class", + "description": "The progress-radial component is a circular progress bar that indicates the current completion of a task. \r\n\r\n## Accessibility guidelines 💡\r\n> Aria attributes and how to display the progress-radial depend on the use case in your application :\r\n>\r\n> - **Case 1 : decorative**\r\n> If the progress-radial is used as a decoration _(if removed, the user doesn't lose any relevant information)_ or in the\r\n> context of another component _(such as progress-radial in a card)_ => **you don't need to show the label nor add an aria-label**.\r\n>\r\n> - **Case 2 : informative**\r\n> If the progress-radial is used to convey important information _(e.g., form completion status, dashboard KPI)_, you need to :\r\n> - **Provide a visible label** that describes the purpose of the progress-radial.\r\n> - **Set the `showLabel` property to `true`** to show the percentage inside the progress-radial.\r\n> - Optionally, use aria-label to provide an accessible name if a visible label is not present.", + "name": "ProgressRadial", + "cssProperties": [ + { + "description": "The width of the line that represents the rail of the progress radial", + "name": "--wcs-progress-radial-rail-width" + }, + { + "description": "The space between each rail of the progress radial", + "name": "--wcs-progress-radial-rail-spacing" + }, + { + "description": "The color of the rail of the progress radial", + "name": "--wcs-progress-radial-rail-color" + }, + { + "description": "The background color of the bar on top of the rail", + "name": "--wcs-progress-radial-value-background-color" + }, + { + "description": "The color of the label inside the progress radial", + "name": "--wcs-progress-radial-label-color" + }, + { + "description": "The font size of the label inside the progress radial", + "name": "--wcs-progress-radial-label-font-size" + }, + { + "description": "The font weight of the label inside the progress radial", + "name": "--wcs-progress-radial-label-font-weight" + }, + { + "description": "The font size of the percentage inside the progress radial", + "name": "--wcs-progress-radial-label-percentage-font-size" + }, + { + "description": "The duration of the animation of the progress radial", + "name": "--wcs-progress-radial-animation-duration" + } + ], + "members": [ + { + "kind": "field", + "name": "el", + "type": { + "text": "HTMLWcsProgressRadialElement" + }, + "privacy": "private" + }, + { + "kind": "field", + "name": "nativeProgress", + "type": { + "text": "HTMLDivElement" + }, + "privacy": "private" + }, + { + "kind": "field", + "name": "inheritedAttributes", + "type": { + "text": "{ [k: string]: any }" + }, + "privacy": "private", + "default": "{}" + }, + { + "kind": "field", + "name": "backgroundImageSize", + "type": { + "text": "number" + }, + "privacy": "private", + "default": "120", + "description": "The initial background image size (120x120) as specified in the background-image css property of .progress-circle" + }, + { + "kind": "field", + "name": "size", + "type": { + "text": "number" + }, + "default": "120", + "description": "The size of the progress radial (in px)" + }, + { + "kind": "field", + "name": "showLabel", + "type": { + "text": "boolean" + }, + "default": "false", + "description": "Whether the component should display the % label inside" + }, + { + "kind": "field", + "name": "value", + "type": { + "text": "number" + }, + "default": "0", + "description": "The value of the progress radial. Prefer values between 0 and 100." + }, + { + "kind": "method", + "name": "setAriaAttribute", + "parameters": [ + { + "name": "attr", + "type": { + "text": "AriaAttributeName" + } + }, + { + "name": "value", + "type": { + "text": "string | null | undefined" + } + } + ] + }, + { + "kind": "method", + "name": "render" + }, + { + "kind": "method", + "name": "getSvgStyle" + }, + { + "kind": "method", + "name": "getSize" + } + ], + "attributes": [ + { + "name": "size", + "fieldName": "size", + "type": { + "text": "number" + } + }, + { + "name": "show-label", + "fieldName": "showLabel", + "type": { + "text": "boolean" + } + }, + { + "name": "value", + "fieldName": "value", + "type": { + "text": "number" + } + } + ], + "tagName": "wcs-progress-radial", + "events": [], + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ProgressRadial", + "declaration": { + "name": "ProgressRadial", + "module": "components/progress-radial/progress-radial.tsx" + } + }, + { + "kind": "custom-element-definition", + "name": "wcs-progress-radial", + "declaration": { + "name": "ProgressRadial", + "module": "components/progress-radial/progress-radial.tsx" + } + } + ] + }, { "kind": "javascript-module", "path": "components/radio-group/radio-group-interface.ts", @@ -14694,6 +14694,13 @@ }, "privacy": "private" }, + { + "kind": "field", + "name": "internals", + "type": { + "text": "ElementInternals" + } + }, { "kind": "field", "name": "inheritedAttributes", @@ -16455,6 +16462,13 @@ }, "privacy": "private" }, + { + "kind": "field", + "name": "internals", + "type": { + "text": "ElementInternals" + } + }, { "kind": "field", "name": "switchId", @@ -16509,6 +16523,21 @@ "default": "false", "description": "Specify whether the switch is disabled or not." }, + { + "kind": "field", + "name": "required", + "type": { + "text": "boolean" + }, + "default": "false", + "description": "If `true`, the user must fill in a value before submitting a form." + }, + { + "kind": "method", + "name": "updateFormValue", + "privacy": "private", + "description": "Updates the form value based on the checkbox state" + }, { "kind": "method", "name": "handleChange", @@ -16630,6 +16659,13 @@ "type": { "text": "boolean" } + }, + { + "name": "required", + "fieldName": "required", + "type": { + "text": "boolean" + } } ], "tagName": "wcs-switch", diff --git a/src/components.d.ts b/src/components.d.ts index 9da29b5ee83c1d260e145e04282ad2b7443d5338..8205c79ef2273a8d82bd17a001c31312a7d1c8b7 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -1934,6 +1934,10 @@ export namespace Components { * The name of the control, automatically set by the radio group. (You shouldn't set this prop by yourself) */ "name": string; + /** + * If `true`, the radio is required + */ + "required": boolean; "setAriaAttribute": (attr: AriaAttributeName, value: string | null | undefined) => Promise; "setTabIndex": (value: number) => Promise; "updateState": () => Promise; @@ -2258,6 +2262,10 @@ export namespace Components { */ "labelAlignment": SwitchLabelAlignment; "name": string; + /** + * If `true`, the user must fill in a value before submitting a form. + */ + "required": boolean; "setAriaAttribute": (attr: AriaAttributeName, value: string | null | undefined) => Promise; } /** @@ -6682,6 +6690,10 @@ declare namespace LocalJSX { * Emitted when the radio is clicked or Space/Enter is pressed above an unchecked radio */ "onWcsRadioClick"?: (event: WcsRadioCustomEvent) => void; + /** + * If `true`, the radio is required + */ + "required"?: boolean; /** * Sets a unique value for each radio, used to identify which radio button in a group is selected */ @@ -7022,6 +7034,10 @@ declare namespace LocalJSX { * Emitted when the switch has focus. */ "onWcsFocus"?: (event: WcsSwitchCustomEvent) => void; + /** + * If `true`, the user must fill in a value before submitting a form. + */ + "required"?: boolean; } /** * Tab content component. diff --git a/src/components/checkbox/checkbox.e2e.playwright.ts b/src/components/checkbox/checkbox.e2e.playwright.ts index 7bf112f6ac6862a9ceb08094e69787f3fc5283d9..65b5e39f149181e0940f4e294adf9b8e570e1ca6 100644 --- a/src/components/checkbox/checkbox.e2e.playwright.ts +++ b/src/components/checkbox/checkbox.e2e.playwright.ts @@ -44,4 +44,82 @@ test.describe('Checkbox component', () => { expect(wcsChangeEventSpy).toHaveReceivedEventDetail({ checked: true }); }); }); + + test.describe('Form Integration', () => { + test('should reflect validity from native input to form-associated custom element', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ Required checkbox +
+ `); + + // When - check the validity before checking the checkbox + const isValidBefore = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + + // Then - form should be invalid because required checkbox is not checked + expect(isValidBefore).toBe(false); + + // When - check the checkbox + const checkbox = page.locator('wcs-checkbox'); + await checkbox.click(); + await page.waitForChanges(); + + // Then - form should be valid now + const isValidAfter = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + expect(isValidAfter).toBe(true); + }); + + test('should include checkbox value in form submission when checked', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ Test checkbox + +
+ `); + + // Set up form submission handler to capture submitted data + await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const formData = new FormData(form); + // Track if checkbox was included in submission + (window as any)['submittedData'] = { + hasCheckbox: formData.has('test-checkbox'), + checkboxValue: formData.get('test-checkbox') + }; + }); + }); + + // When - submit form with unchecked checkbox + const submitBtn = page.locator('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - checkbox value should not be included in form submission + const uncheckedResult = await page.evaluate(() => (window as any)['submittedData']); + expect(uncheckedResult.hasCheckbox).toBe(false); + + // When - check the checkbox and submit again + const checkbox = page.locator('wcs-checkbox'); + await checkbox.click(); + await page.waitForChanges(); + + await submitBtn.click(); + await page.waitForChanges(); + + // Then - checkbox value should be included in form submission + const checkedResult = await page.evaluate(() => (window as any)['submittedData']); + expect(checkedResult.hasCheckbox).toBe(true); + expect(checkedResult.checkboxValue).toBe('on'); + }); + }); }); diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx index 99e1b3c232f62e6031a78fa16eabe1d10056a8b4..0d866a23389ee23fe8d4936ff7cf287cc24e8a1b 100644 --- a/src/components/checkbox/checkbox.tsx +++ b/src/components/checkbox/checkbox.tsx @@ -8,11 +8,17 @@ import { Host, Method, Element, - Listen + Listen, + AttachInternals } from '@stencil/core'; import { CheckboxChangeEventDetail, CheckboxLabelAlignment } from './checkbox-interface'; import { AriaAttributeName, MutableAriaAttribute } from "../../utils/mutable-aria-attribute"; -import { inheritAriaAttributes, inheritAttributes, setOrRemoveAttribute } from "../../utils/helpers"; +import { + inheritAriaAttributes, + inheritAttributes, + reflectValidityFromNativeInputToCustomElement, + setOrRemoveAttribute +} from "../../utils/helpers"; import { ControlComponentWithLabel, getSlottedContentText } from "../../utils/control-component-interface"; const CHECKBOX_INHERITED_ATTRS = ['tabindex', 'title']; @@ -63,6 +69,7 @@ const CHECKBOX_INHERITED_ATTRS = ['tabindex', 'title']; */ @Component({ tag: 'wcs-checkbox', + formAssociated: true, styleUrl: 'checkbox.scss', shadow: { delegatesFocus: true, @@ -70,6 +77,7 @@ const CHECKBOX_INHERITED_ATTRS = ['tabindex', 'title']; }) export class Checkbox implements ComponentInterface, MutableAriaAttribute, ControlComponentWithLabel { @Element() private el!: HTMLElement; + @AttachInternals() internals: ElementInternals; private nativeInput!: HTMLInputElement; private inheritedAttributes: { [k: string]: any } = {}; private checkboxId = `wcs-checkbox-${checkboxIds++}`; @@ -120,10 +128,25 @@ export class Checkbox implements ComponentInterface, MutableAriaAttribute, Contr ...inheritAriaAttributes(this.el), ...inheritAttributes(this.el, CHECKBOX_INHERITED_ATTRS), }; + } + + /** + * Updates the form value based on the checkbox state + * @private + */ + private updateFormValue() { + if (this.checked) { + const value = this.nativeInput.value || 'on'; + this.internals.setFormValue(value); + } else { + this.internals.setFormValue(null); + } + reflectValidityFromNativeInputToCustomElement(this.nativeInput, this.internals); } componentDidLoad() { this.onSlotChange(); + this.updateFormValue(); } @Method() @@ -164,6 +187,10 @@ export class Checkbox implements ComponentInterface, MutableAriaAttribute, Contr this.indeterminate = false; this.checked = !this.checked; + this.nativeInput.checked = this.checked; + + this.updateFormValue(); + this.wcsChange.emit({ checked: this.checked, }); @@ -204,6 +231,7 @@ export class Checkbox implements ComponentInterface, MutableAriaAttribute, Contr checked={this.checked} class="wcs-checkbox" type="checkbox" + required={this.required} ref={(el) => (this.nativeInput = el)} name={this.name} disabled={this.disabled} diff --git a/src/components/counter/counter.e2e.playwright.ts b/src/components/counter/counter.e2e.playwright.ts index 3ba6949269ac98aefecc77a733438ecd3c0d80ed..aaedaef791566862a488e0308a7e8b169718ac3c 100644 --- a/src/components/counter/counter.e2e.playwright.ts +++ b/src/components/counter/counter.e2e.playwright.ts @@ -244,4 +244,40 @@ test.describe('counter', () => { await expect(page.locator('wcs-counter')).toHaveJSProperty('value', 0); await expect(incrementButton).toHaveJSProperty('disabled', true); }); + + test.describe('Form Integration', () => { + test('should include counter value in form submission when checked', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ Test counter + +
+ `); + + // Set up form submission handler to capture submitted data + await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const formData = new FormData(form); + // Track if counter was included in submission + (window as any)['submittedData'] = { + hasCounter: formData.has('test-counter'), + counterValue: formData.get('test-counter') + }; + }); + }); + + // When - submit form with empty counter + const submitBtn = page.locator('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - counter value should be included in form submission + const result = await page.evaluate(() => (window as any)['submittedData']); + expect(result.hasCounter).toBe(true); + expect(result.counterValue).toBe('0'); + }); + }); }); diff --git a/src/components/counter/counter.tsx b/src/components/counter/counter.tsx index 68f40b34dfdef8647eb4d8428b98a821e7fd4c33..d1439be5d8e12aaecbf0fe4969aa583293e9068e 100644 --- a/src/components/counter/counter.tsx +++ b/src/components/counter/counter.tsx @@ -1,4 +1,5 @@ import { + AttachInternals, Component, ComponentInterface, Element, Event, @@ -57,9 +58,11 @@ const COUNTER_INHERITED_ATTRS = ['tabindex', 'title']; shadow: { delegatesFocus: true }, + formAssociated: true }) export class Counter implements ComponentInterface, MutableAriaAttribute { @Element() private el!: HTMLElement; + @AttachInternals() private internals!: ElementInternals; private spinButton!: HTMLSpanElement; private counterContainer!: HTMLDivElement; private inheritedAttributes: { [k: string]: any } = {}; @@ -157,6 +160,8 @@ export class Counter implements ComponentInterface, MutableAriaAttribute { this.setMinimumIfValueIsUndefinedOrNull(); this.ensureValueIsNotOutOfMinMax(); this.updateDisplayValueIfNoAnimationRunning(); + // Always set form value, even if value is 0 + this.internals.setFormValue(String(this.value)); } private updateDisplayValueIfNoAnimationRunning() { diff --git a/src/components/counter/readme.md b/src/components/counter/readme.md index 684218ab2383633d283d2dec6c8b71eeffe74034..a009c2b550e827df741a95673b100bb82a79aab6 100644 --- a/src/components/counter/readme.md +++ b/src/components/counter/readme.md @@ -16,6 +16,7 @@ For larger or specific ranges, please use [wcs-input (type number)](.?path=/docs | `label` _(required)_ | `label` | The label of the counter.
e.g. Number of passengers, train carriages, railroad tracks... | `string` | `undefined` | | `max` | `max` | The maximum value of the counter. If the value of the max attribute isn't set, then the element has no maximum value. | `number` | `undefined` | | `min` | `min` | The minimum value of the counter. If the value of the min attribute isn't set, then the element has no minimum value. | `number` | `undefined` | +| `name` | `name` | The name of the counter | `string` | `undefined` | | `size` | `size` | Specify the size (height) of the counter. | `"l" \| "m"` | `'m'` | | `step` | `step` | Defines by how much the counter will be incremented or decremented. | `number` | `1` | | `value` _(required)_ | `value` | The current value of the counter. | `number` | `undefined` | diff --git a/src/components/input/input.e2e.playwright.ts b/src/components/input/input.e2e.playwright.ts index d1a85975badc00ef6175c55f240c60f9358b7aa8..a6a1d00088fb86e6132c7b8ccd93524c543647cf 100644 --- a/src/components/input/input.e2e.playwright.ts +++ b/src/components/input/input.e2e.playwright.ts @@ -189,4 +189,178 @@ test.describe('Input component', () => { await expect(input).toHaveJSProperty('value', 'Default value'); await expect(page.locator('wcs-input input')).toHaveJSProperty('value', 'Default value'); }); + + test.describe('Form Integration', () => { + test('should reflect validity from native input to form-associated custom element for required field', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ +
+ `); + + // When - check the validity before filling the input + const isValidBefore = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + + // Then - form should be invalid because required input is empty + expect(isValidBefore).toBe(false); + + // When - fill the input + const input = page.locator('wcs-input'); + await input.click(); + await input.type('Some value'); + await page.waitForChanges(); + + // Blur the input to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - form should be valid now + const isValidAfter = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + expect(isValidAfter).toBe(true); + }); + + test('should include input value in form submission', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ + +
+ `); + + // Set up form submission handler to capture submitted data + await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const formData = new FormData(form); + // Track input value in submission + (window as any)['submittedData'] = { + hasInput: formData.has('test-input'), + inputValue: formData.get('test-input') + }; + }); + }); + + // When - submit form with empty input + const submitBtn = page.locator('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - input value should be included in form submission (empty string) + const emptyResult = await page.evaluate(() => (window as any)['submittedData']); + expect(emptyResult.hasInput).toBe(true); + expect(emptyResult.inputValue).toBe(''); + + // When - fill the input and submit again + const input = page.locator('wcs-input'); + await input.click(); + await input.type('Test value'); + await page.waitForChanges(); + + await submitBtn.click(); + await page.waitForChanges(); + + // Then - input value should be included in form submission + const filledResult = await page.evaluate(() => (window as any)['submittedData']); + expect(filledResult.hasInput).toBe(true); + expect(filledResult.inputValue).toBe('Test value'); + }); + + test('should validate input against pattern attribute', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ + +
+ `); + + // Set up validity tracking + await page.evaluate(() => { + (window as any)['checkValidity'] = () => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }; + }); + + // When - fill the input with invalid email + const input = page.locator('wcs-input'); + await input.click(); + await input.type('not-an-email'); + await page.waitForChanges(); + + // Blur the input to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - form and input should be invalid + const invalidCheck = await page.evaluate(() => (window as any)['checkValidity']()); + expect(invalidCheck).toBe(false); + + // When - fix the input and check again + await input.click(); + await input.type('valid@email.com'); + await page.waitForChanges(); + + // Blur the input to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - form and input should be valid now + const validCheck = await page.evaluate(() => (window as any)['checkValidity']()); + expect(validCheck).toBe(true); + }); + + test('should validate min/max length attributes', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ +
+ `); + + // Set up validity tracking + await page.evaluate(() => { + (window as any)['checkValidity'] = () => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }; + }); + + // When - fill the input with a value too short + const input = page.locator('wcs-input'); + await input.click(); + await input.type('ab'); + await page.waitForChanges(); + + // Blur the input to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - input should be invalid due to being too short + const invalidCheck = await page.evaluate(() => (window as any)['checkValidity']()); + expect(invalidCheck).toBe(false); + + // When - fill the input with a valid length + await input.click(); + await input.type('valid'); + await page.waitForChanges(); + + // Blur the input to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - form and input should be valid now + const validCheck = await page.evaluate(() => (window as any)['checkValidity']()); + expect(validCheck).toBe(true); + }); + }); }); diff --git a/src/components/input/input.scss b/src/components/input/input.scss index 3f8f96cdc11ea7a463a569d1621b947eaf3b95a8..7312c1ed54ee939b7cb0208536bd73e02fba7921 100644 --- a/src/components/input/input.scss +++ b/src/components/input/input.scss @@ -136,6 +136,10 @@ padding-right: calc(var(--wcs-input-padding-horizontal-l)); } +:host(:invalid) { + outline-color: var(--wcs-input-border-color-error) !important; +} + :host([size=m]) { // Default --wcs-input-host-height: var(--wcs-input-height-m); --wcs-input-font-size: var(--wcs-input-font-size-m); diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx index 9b7c1c00e51d755b1dc4e4c9bccfbf540bbfaa44..f2cc637990c5a33e37d7cb36e3b7d19d68a9cbf7 100644 --- a/src/components/input/input.tsx +++ b/src/components/input/input.tsx @@ -1,4 +1,5 @@ import { + AttachInternals, Build, Component, ComponentInterface, @@ -16,7 +17,8 @@ import { debounceEvent, findItemLabel, inheritAriaAttributes, - inheritAttributes, + inheritAttributes, + reflectValidityFromNativeInputToCustomElement, setOrRemoveAttribute } from '../../utils/helpers'; import { @@ -93,9 +95,11 @@ const INPUT_INHERITED_ATTRS = ['tabindex', 'title']; tag: 'wcs-input', styleUrl: 'input.scss', shadow: { delegatesFocus: true }, + formAssociated: true }) export class Input implements ComponentInterface, MutableAriaAttribute { @Element() private el!: HTMLElement; + @AttachInternals() internals: ElementInternals; private nativeInput?: HTMLInputElement; private inputId = `wcs-input-${inputIds++}`; private inheritedAttributes: { [k: string]: any } = {}; @@ -293,7 +297,7 @@ export class Input implements ComponentInterface, MutableAriaAttribute { * Emitted when the input has focus. */ @Event() wcsFocus!: EventEmitter; - + componentWillLoad() { this.inheritedAttributes = { ...inheritAriaAttributes(this.el), @@ -301,6 +305,11 @@ export class Input implements ComponentInterface, MutableAriaAttribute { }; } + componentDidLoad() { + this.internals.setFormValue(this.nativeInput.value); + reflectValidityFromNativeInputToCustomElement(this.nativeInput, this.internals); + } + connectedCallback() { this.debounceChanged(); if (Build.isBrowser) { @@ -347,11 +356,31 @@ export class Input implements ComponentInterface, MutableAriaAttribute { return typeof this.value === 'number' ? this.value.toString() : (this.value || '').toString(); } - + private onInput = (ev: Event) => { const input = ev.target as HTMLInputElement | null; if (input) { this.value = input.value || ''; + reflectValidityFromNativeInputToCustomElement(input, this.internals); + + if (this.type === 'file' && input.files && input.files.length > 0) { + /* + For file inputs, we need to use FormData to properly handle File objects, + value JS property of native input serialize into string the value + */ + const fileFormData = new FormData(); + if (this.multiple) { + for (let i = 0; i < input.files.length; i++) { + fileFormData.append(this.name, input.files[i]); + } + } else { + fileFormData.append(this.name, input.files[0]); + } + this.internals.setFormValue(fileFormData); + } else { + // For non-file inputs, use the string value + this.internals.setFormValue(input.value, input.value); + } } this.wcsInput.emit(ev as KeyboardEvent); } diff --git a/src/components/radio/radio.tsx b/src/components/radio/radio.tsx index c1e5e7adcc6d4b31bb2c2db403c67db720e82fc3..4921a53489e9c3f762347b959168cd2ba25fda3c 100644 --- a/src/components/radio/radio.tsx +++ b/src/components/radio/radio.tsx @@ -117,6 +117,11 @@ export class Radio implements ComponentInterface, MutableAriaAttribute { * The label text displayed for the user */ @Prop({ mutable: true, reflect: true }) label: string; + + /** + * If `true`, the radio is required + */ + @Prop() required: boolean = false; /** * If `true`, the user cannot interact with the radio. @@ -232,6 +237,7 @@ export class Radio implements ComponentInterface, MutableAriaAttribute { name={this.name} value={this.value} checked={this.checked} // Initial checked state of native input + required={this.required} disabled={this.disabled} onChange={this.onChange.bind(this)} onFocus={this.onFocus.bind(this)} diff --git a/src/components/radio/readme.md b/src/components/radio/readme.md index 14d66045890d1eb181c5006f1439b1290860a074..392759f5ac08910a69e2031c92c768bbf2194fd0 100644 --- a/src/components/radio/readme.md +++ b/src/components/radio/readme.md @@ -15,6 +15,7 @@ The radio component should always be wrapped in a `wcs-radio-group`. | ---------- | ---------- | ---------------------------------------------------------------------------------------------- | --------- | ----------- | | `disabled` | `disabled` | If `true`, the user cannot interact with the radio. | `boolean` | `false` | | `label` | `label` | The label text displayed for the user | `string` | `undefined` | +| `required` | `required` | If `true`, the radio is required | `boolean` | `false` | | `value` | `value` | Sets a unique value for each radio, used to identify which radio button in a group is selected | `any` | `undefined` | diff --git a/src/components/select/select.e2e.playwright.ts b/src/components/select/select.e2e.playwright.ts index b6be2ae252ba4c1189092baa637c19807b256909..a7ed13afa4ccc903828041cc9fac4c29c3438594 100644 --- a/src/components/select/select.e2e.playwright.ts +++ b/src/components/select/select.e2e.playwright.ts @@ -1438,4 +1438,97 @@ test.describe('Select component', () => { // Then - Verify label is updated await expect(label).toHaveText('Two, Three'); }); + + test.describe('Form Integration', () => { + test('should reflect validity from select for required field', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ + One + Two + +
+ `); + + // When - check the validity before filling the select + const isValidBefore = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + + // Then - form should be invalid because required select is empty + expect(isValidBefore).toBe(false); + + // When + const select = page.locator('wcs-select'); + const firstSelectOption = page.locator('wcs-select > wcs-select-option').first(); + await select.click(); + await firstSelectOption.click(); + await page.waitForChanges(); + + // Blur the select to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - form should be valid now + const isValidAfter = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + expect(isValidAfter).toBe(true); + }); + + test('should include select value in form submission', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ + One + Two + + +
+ `); + + // Set up form submission handler to capture submitted data + await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const formData = new FormData(form); + // Track select value in submission + (window as any)['submittedData'] = { + hasSelect: formData.has('test-select'), + selectValue: formData.get('test-select') + }; + }); + }); + + // When - submit form with empty select + const submitBtn = page.locator('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - select value should be included in form submission (empty string) + const emptyResult = await page.evaluate(() => (window as any)['submittedData']); + expect(emptyResult.hasSelect).toBe(false); // comme un select natif, si on sélectionne aucune valeur il n'est pas submit dans le form-data + expect(emptyResult.selectValue).toBe(null); + + // When - fill the select and submit again + const select = page.locator('wcs-select'); + const firstSelectOption = page.locator('wcs-select > wcs-select-option').first(); + await select.click(); + await firstSelectOption.click(); + await page.waitForChanges(); + + await submitBtn.click(); + await page.waitForChanges(); + + // Then - select value should be included in form submission + const filledResult = await page.evaluate(() => (window as any)['submittedData']); + expect(filledResult.hasSelect).toBe(true); + expect(filledResult.selectValue).toBe('1'); + }); + }); }); diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index d329490298a0675697dd244ddd0c08b70ac48e75..37a96d62807c47f79933c68c1da5ae8a88ba7571 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -1,4 +1,5 @@ import { + AttachInternals, Component, ComponentInterface, Element, @@ -147,10 +148,12 @@ const SELECT_INHERITED_ATTRS = ['tabindex', 'title']; @Component({ tag: 'wcs-select', styleUrl: 'select.scss', - shadow: true + shadow: true, + formAssociated: true }) export class Select implements ComponentInterface, MutableAriaAttribute { @Element() private el!: HTMLWcsSelectElement; + @AttachInternals() internals: ElementInternals; private inheritedAttributes: { [k: string]: any } = {}; private stateService!: Interpreter; @@ -303,6 +306,12 @@ export class Select implements ComponentInterface, MutableAriaAttribute { @Watch('value') onValueChangeHandler(newValue: any) { this.updateSelectedValue(newValue); + this.internals.setFormValue(this.value); + if(this.required && !this.value) { + this.internals.setValidity({valueMissing: true}, "You must select a value"); + } else { + this.internals.setValidity({valueMissing: false}); + } } /** @@ -417,6 +426,11 @@ export class Select implements ComponentInterface, MutableAriaAttribute { componentDidLoad() { this.optionsEl = this.el.shadowRoot.querySelector('.wcs-select-options'); this.controlEl = this.el.shadowRoot.querySelector('.wcs-select-control'); + + this.internals.setFormValue(this.value); + if(this.required && !this.value) { + this.internals.setValidity({valueMissing: true}, "You must select a value"); + } const stateMachine = Machine( SELECT_MACHINE_CONFIG, diff --git a/src/components/switch/readme.md b/src/components/switch/readme.md index 5c4c983fab0b14ad0d8afdd13da5f9f3387690ab..fe97c0580f4e3d8b923ccc54c18fb2762d666769 100644 --- a/src/components/switch/readme.md +++ b/src/components/switch/readme.md @@ -10,12 +10,13 @@ The switch component is a control used to switch between on and off state. ## Properties -| Property | Attribute | Description | Type | Default | -| ---------------- | ----------------- | ----------------------------------------------------------- | ------------------------------- | --------------- | -| `checked` | `checked` | If `true`, the switch is selected. | `boolean` | `false` | -| `disabled` | `disabled` | Specify whether the switch is disabled or not. | `boolean` | `false` | -| `labelAlignment` | `label-alignment` | Specifie the alignment of the switch with the label content | `"bottom" \| "center" \| "top"` | `'center'` | -| `name` | `name` | | `string` | `this.switchId` | +| Property | Attribute | Description | Type | Default | +| ---------------- | ----------------- | ------------------------------------------------------------------ | ------------------------------- | --------------- | +| `checked` | `checked` | If `true`, the switch is selected. | `boolean` | `false` | +| `disabled` | `disabled` | Specify whether the switch is disabled or not. | `boolean` | `false` | +| `labelAlignment` | `label-alignment` | Specifie the alignment of the switch with the label content | `"bottom" \| "center" \| "top"` | `'center'` | +| `name` | `name` | | `string` | `this.switchId` | +| `required` | `required` | If `true`, the user must fill in a value before submitting a form. | `boolean` | `false` | ## Events diff --git a/src/components/switch/switch.e2e.playwright.ts b/src/components/switch/switch.e2e.playwright.ts index 92dee1c13cf359c019d3377e876637b7a7e58cab..f4753cd3a58a4d720bc954b9a619275eb8d0fb26 100644 --- a/src/components/switch/switch.e2e.playwright.ts +++ b/src/components/switch/switch.e2e.playwright.ts @@ -47,4 +47,82 @@ test.describe('Switch component', () => { expect(changeSpy).toHaveReceivedEventDetail({ checked: true }); }); }); + + test.describe('Form Integration', () => { + test('should reflect validity from native input to form-associated custom element', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ Required switch +
+ `); + + // When - check the validity before checking the switch + const isValidBefore = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + + // Then - form should be invalid because required switch is not checked + expect(isValidBefore).toBe(false); + + // When - check the switch + const switchElement = page.locator('wcs-switch'); + await switchElement.click(); + await page.waitForChanges(); + + // Then - form should be valid now + const isValidAfter = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + expect(isValidAfter).toBe(true); + }); + + test('should include switch value in form submission when checked', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ Test switch + +
+ `); + + // Set up form submission handler to capture submitted data + await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const formData = new FormData(form); + // Track if switch was included in submission + (window as any)['submittedData'] = { + hasSwitch: formData.has('test-switch'), + switchValue: formData.get('test-switch') + }; + }); + }); + + // When - submit form with unchecked switch + const submitBtn = page.locator('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - switch value should not be included in form submission + const uncheckedResult = await page.evaluate(() => (window as any)['submittedData']); + expect(uncheckedResult.hasSwitch).toBe(false); + + // When - check the switch and submit again + const switchElement = page.locator('wcs-switch'); + await switchElement.click(); + await page.waitForChanges(); + + await submitBtn.click(); + await page.waitForChanges(); + + // Then - switch value should be included in form submission + const checkedResult = await page.evaluate(() => (window as any)['submittedData']); + expect(checkedResult.hasSwitch).toBe(true); + expect(checkedResult.switchValue).toBe('on'); + }); + }); }); diff --git a/src/components/switch/switch.tsx b/src/components/switch/switch.tsx index 4a776023cde7a9fef187cd3ac4e70ae9b8b544ba..f0c889b2612fda311a335922e5a69a9b8bbf5ad1 100644 --- a/src/components/switch/switch.tsx +++ b/src/components/switch/switch.tsx @@ -1,4 +1,5 @@ import { + AttachInternals, Component, ComponentInterface, Element, @@ -12,7 +13,12 @@ import { } from '@stencil/core'; import { SwitchChangeEventDetail, SwitchLabelAlignment } from './switch-interface'; import { AriaAttributeName, MutableAriaAttribute } from "../../utils/mutable-aria-attribute"; -import { inheritAriaAttributes, inheritAttributes, setOrRemoveAttribute } from "../../utils/helpers"; +import { + inheritAriaAttributes, + inheritAttributes, + reflectValidityFromNativeInputToCustomElement, + setOrRemoveAttribute +} from "../../utils/helpers"; import { ControlComponentWithLabel, getSlottedContentText } from "../../utils/control-component-interface"; const SWITCH_INHERITED_ATTRS = ['tabindex']; @@ -53,10 +59,12 @@ const SWITCH_INHERITED_ATTRS = ['tabindex']; styleUrl: 'switch.scss', shadow: { delegatesFocus: true, - } + }, + formAssociated: true }) export class Switch implements ComponentInterface, MutableAriaAttribute, ControlComponentWithLabel { @Element() private el!: HTMLElement; + @AttachInternals() internals: ElementInternals; private switchId = `wcs-switch-${switchIds++}`; private nativeInput!: HTMLInputElement; private inheritedAttributes: { [k: string]: any } = {}; @@ -78,6 +86,11 @@ export class Switch implements ComponentInterface, MutableAriaAttribute, Control */ @Prop({ reflect: true }) disabled: boolean = false; + /** + * If `true`, the user must fill in a value before submitting a form. + */ + @Prop() required: boolean = false + /** * Emitted when the checked property has changed. */ @@ -93,6 +106,19 @@ export class Switch implements ComponentInterface, MutableAriaAttribute, Control */ @Event() wcsBlur!: EventEmitter; + /** + * Updates the form value based on the checkbox state + * @private + */ + private updateFormValue() { + if (this.checked) { + const value = this.nativeInput.value || 'on'; + this.internals.setFormValue(value); + } else { + this.internals.setFormValue(null); + } + reflectValidityFromNativeInputToCustomElement(this.nativeInput, this.internals); + } handleChange(ev: Event) { ev.stopImmediatePropagation(); @@ -121,6 +147,10 @@ export class Switch implements ComponentInterface, MutableAriaAttribute, Control if (this.disabled) return; this.checked = !this.checked; + this.nativeInput.checked = this.checked; + + this.updateFormValue(); + this.wcsChange.emit({ checked: this.checked, }); @@ -141,6 +171,10 @@ export class Switch implements ComponentInterface, MutableAriaAttribute, Control }; } + componentDidLoad() { + this.updateFormValue(); + } + @Method() async setAriaAttribute(attr: AriaAttributeName, value: string | null | undefined) { setOrRemoveAttribute(this.nativeInput, attr, value); @@ -161,6 +195,7 @@ export class Switch implements ComponentInterface, MutableAriaAttribute, Control onFocus={this.handleFocus.bind(this)} checked={this.checked} id={this.name} + required={this.required} class="wcs-switch" type="checkbox" name={this.name} diff --git a/src/components/textarea/textarea.e2e.playwright.ts b/src/components/textarea/textarea.e2e.playwright.ts index e37da3f220f09575b535ea515f7f7ccc49f099c6..53c4c0aae843f2d7bc6a12da5ba8518a6e2b8624 100644 --- a/src/components/textarea/textarea.e2e.playwright.ts +++ b/src/components/textarea/textarea.e2e.playwright.ts @@ -167,4 +167,132 @@ test.describe('Textarea component', () => { await expect(textarea).toHaveJSProperty('value', 'Default value'); await expect(page.locator('wcs-textarea textarea')).toHaveJSProperty('value', 'Default value'); }); + test.describe('Form Integration', () => { + test('should reflect validity from native textarea to form-associated custom element for required field', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ +
+ `); + + // When - check the validity before filling the textarea + const isValidBefore = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + + // Then - form should be invalid because required textarea is empty + expect(isValidBefore).toBe(false); + + // When - fill the textarea + const textarea = page.locator('wcs-textarea'); + await textarea.click(); + await textarea.type('Some value'); + await page.waitForChanges(); + + // Blur the textarea to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - form should be valid now + const isValidAfter = await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }); + expect(isValidAfter).toBe(true); + }); + + test('should include textarea value in form submission', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ + +
+ `); + + // Set up form submission handler to capture submitted data + await page.evaluate(() => { + const form = document.getElementById('test-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const formData = new FormData(form); + // Track textarea value in submission + (window as any)['submittedData'] = { + hasTextarea: formData.has('test-textarea'), + textareaValue: formData.get('test-textarea') + }; + }); + }); + + // When - submit form with empty textarea + const submitBtn = page.locator('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - textarea value should be included in form submission (empty string) + const emptyResult = await page.evaluate(() => (window as any)['submittedData']); + expect(emptyResult.hasTextarea).toBe(true); + expect(emptyResult.textareaValue).toBe(''); + + // When - fill the textarea and submit again + const textarea = page.locator('wcs-textarea'); + await textarea.click(); + await textarea.type('Test value'); + await page.waitForChanges(); + + await submitBtn.click(); + await page.waitForChanges(); + + // Then - textarea value should be included in form submission + const filledResult = await page.evaluate(() => (window as any)['submittedData']); + expect(filledResult.hasTextarea).toBe(true); + expect(filledResult.textareaValue).toBe('Test value'); + }); + + test('should validate min/max length attributes', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` +
+ +
+ `); + + // Set up validity tracking + await page.evaluate(() => { + (window as any)['checkValidity'] = () => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }; + }); + + // When - fill the textarea with a value too short + const textarea = page.locator('wcs-textarea'); + await textarea.click(); + await textarea.type('ab'); + await page.waitForChanges(); + + // Blur the textarea to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - textarea should be invalid due to being too short + const invalidCheck = await page.evaluate(() => (window as any)['checkValidity']()); + expect(invalidCheck).toBe(false); + + // When - fill the textarea with a valid length + await textarea.click(); + await textarea.type('valid'); + await page.waitForChanges(); + + // Blur the textarea to trigger validation + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then - form and textarea should be valid now + const validCheck = await page.evaluate(() => (window as any)['checkValidity']()); + expect(validCheck).toBe(true); + }); + }); }); diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 287d38bb2cb503e27b565ccdc773874a6f48c056..f6cc95f5bf7d67c08ef13c5f454d481e554b6259 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -10,13 +10,14 @@ import { Element, Event, Build, - readTask + readTask, + AttachInternals } from '@stencil/core'; import { debounceEvent, inheritAriaAttributes, inheritAttributes, - raf, + raf, reflectValidityFromNativeInputToCustomElement, setOrRemoveAttribute } from '../../utils/helpers'; import { @@ -84,9 +85,11 @@ const TEXTAREA_INHERITED_ATTRS = ['tabindex', 'title']; shadow: { delegatesFocus: true }, + formAssociated: true }) export class Textarea implements ComponentInterface, MutableAriaAttribute { @Element() private el!: HTMLElement; + @AttachInternals() internals: ElementInternals; private nativeInput?: HTMLTextAreaElement; private inputId = `wcs-textarea-${textareaIds++}`; private inheritedAttributes: { [k: string]: any } = {}; @@ -248,7 +251,7 @@ export class Textarea implements ComponentInterface, MutableAriaAttribute { * Emitted when the input has focus. */ @Event() wcsFocus!: EventEmitter; - + connectedCallback() { this.debounceChanged(); if (Build.isBrowser) { @@ -275,6 +278,8 @@ export class Textarea implements ComponentInterface, MutableAriaAttribute { componentDidLoad() { raf(() => this.runAutoGrow()); + this.internals.setFormValue(this.nativeInput.value); + reflectValidityFromNativeInputToCustomElement(this.nativeInput, this.internals); } private runAutoGrow() { @@ -328,6 +333,8 @@ export class Textarea implements ComponentInterface, MutableAriaAttribute { private onInput = (ev: Event) => { if (this.nativeInput) { this.value = this.nativeInput.value; + this.internals.setFormValue(this.value); + reflectValidityFromNativeInputToCustomElement(this.nativeInput, this.internals); } this.wcsInput.emit(ev as KeyboardEvent); } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 58e9d419daeb61ffd05d818cc5d1a8d89b92e404..6cc3ed84b3b25078ad144fc4f964424a97e4cb71 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -51,6 +51,25 @@ export const inheritAttributes = (el: HTMLElement, attributes: string[] = []) => return attributeObject; } +/** + * Reflects the validity state of a native input to the ElementInternals of a custom element. + * This function synchronizes standard HTML5 validations with the ElementInternals API + * + * @param htmlInput - The native HTML input element whose validity state needs to be reflected + * @param internals - The ElementInternals instance of the custom element that will receive the validity state + */ +export function reflectValidityFromNativeInputToCustomElement(htmlInput: HTMLInputElement | HTMLTextAreaElement, internals: ElementInternals) { + const nativeValid = htmlInput.checkValidity(); + const validationMessage = htmlInput.validationMessage; + const validityState = htmlInput.validity; + + if (!nativeValid) { + internals.setValidity(validityState, validationMessage); + } else { + internals.setValidity({}); + } +} + /** * List of available ARIA attributes + `role`. * Removed deprecated attributes. @@ -170,12 +189,12 @@ export const compareLists: (sourceList: T[], newList: T[], compareFn: (v1: T, }; /** - * Normalizes whitespace by replacing multiple consecutive whitespace characters + * Normalizes whitespace by replacing multiple consecutive whitespace characters * with a single space and removes leading and trailing whitespace. - * + * * @param content - String potentially containing multiple whitespace characters to normalize, if the content is null or undefined, it will return an empty string. * @returns Normalized string where sequences of whitespace are replaced by a single space. - * + * * @example * normalizeWhitespace(" Text with spaces ") // Returns "Text with spaces" */ diff --git a/stories/components/form-field/form-field.stories.ts b/stories/components/form-field/form-field.stories.ts index 0774843aa6343de5fcace7479c5c548c6353265a..d7276dc66cfbc07588330725fa96e937a6357ea4 100644 --- a/stories/components/form-field/form-field.stories.ts +++ b/stories/components/form-field/form-field.stories.ts @@ -54,13 +54,32 @@ const ErrorTemplate = (text: string | TemplateResult) => html` `; const InputTemplate: StoryFn> = (args) => html` - - Enter your name - - ${ErrorTemplate(html`Your name is not valid, please do what is necessary +
+ + + Enter your name + + ${ErrorTemplate(html`Your name is not valid, please do what is necessary here.`)} - A name is something that describes a person - + A name is something that describes a person + + + + France + Germany + Japan + + + + + + + What do you think about the fact that you are filling a fake form? + + Does anyone will ever read you? + + +
`; export const Input: StoryObj = { render: (args) => InputTemplate(args, this), diff --git a/stories/components/input/input.stories.ts b/stories/components/input/input.stories.ts index 7a211677f6a6865bea82ddfeba40ab51e71e3cdf..9e829831b574b74880912e3f03fd92c30a783973 100644 --- a/stories/components/input/input.stories.ts +++ b/stories/components/input/input.stories.ts @@ -218,3 +218,30 @@ export const Password: StoryObj = { value: 'superpassword' } } + +export const FormAssociated: StoryObj = { + render: (args) => html` + +
+ ${renderWcsInput({ ...args, size: 'l', ariaLabel: 'Input size L', placeholder: 'Input L', name: 'input-wcs', type: 'file' })} + + + +
+ `, + args: { + style:'width: 300px' + } +}