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, `
+
+ `);
+
+ // 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, `
+
+ `);
+
+ // 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, `
+
+ `);
+
+ // 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, `
+
+ `);
+
+ // 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, `
+
+ `);
+
+ // 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, `
+
+ `);
+
+ // 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, `
+
+ `);
+
+ // 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
+
+
+
+ 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`
+
+
+ `,
+ args: {
+ style:'width: 300px'
+ }
+}