From 6964f52dd2df918bba033b7b74ac0c211eac43cf Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Mon, 28 Apr 2025 13:29:32 +0200 Subject: [PATCH 1/7] feat(radio): add required attribute --- CHANGELOG.md | 1 + angular/projects/wcs-angular/src/lib/proxies.ts | 4 ++-- src/components.d.ts | 8 ++++++++ src/components/radio/radio.tsx | 6 ++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 275729ec..0abdc780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ 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 ### Changed diff --git a/angular/projects/wcs-angular/src/lib/proxies.ts b/angular/projects/wcs-angular/src/lib/proxies.ts index 8dac0841..f7c782f3 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; diff --git a/src/components.d.ts b/src/components.d.ts index 9da29b5e..66f57f72 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; @@ -6682,6 +6686,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 */ diff --git a/src/components/radio/radio.tsx b/src/components/radio/radio.tsx index c1e5e7ad..4921a534 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)} -- GitLab From 8b5852aa5f83041e52b937bea4154609de7cb235 Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Wed, 23 Apr 2025 11:01:00 +0200 Subject: [PATCH 2/7] feat: form-associated input and textarea --- src/components/input/input.e2e.ts | 351 ++++++++++++++++++ src/components/input/input.scss | 4 + src/components/input/input.tsx | 35 +- src/components/radio/readme.md | 1 + src/components/textarea/textarea.e2e.ts | 286 ++++++++++++++ src/components/textarea/textarea.tsx | 13 +- src/utils/helpers.ts | 25 +- .../form-field/form-field.stories.ts | 28 +- stories/components/input/input.stories.ts | 27 ++ 9 files changed, 755 insertions(+), 15 deletions(-) create mode 100644 src/components/input/input.e2e.ts create mode 100644 src/components/textarea/textarea.e2e.ts diff --git a/src/components/input/input.e2e.ts b/src/components/input/input.e2e.ts new file mode 100644 index 00000000..4ba1d422 --- /dev/null +++ b/src/components/input/input.e2e.ts @@ -0,0 +1,351 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { setWcsContent } from "../../utils/tests"; + +describe('Input component', () => { + describe('Events', () => { + it('Should fire wcsInput event once when user typing one char', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const input = await page.find('wcs-input'); + const inputEvent = await page.spyOnEvent('wcsInput'); + + // When + await input.click(); + await input.press('B'); + + // Then + expect(inputEvent).toHaveReceivedEventTimes(1); + }); + it('Should fire wcsInput event multiple times when user typing multiple chars', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const input = await page.find('wcs-input'); + const inputEvent = await page.spyOnEvent('wcsInput'); + + // When + await input.click(); + await input.press('B'); + await input.press('o'); + await input.press('n'); + await input.press('j'); + await input.press('o'); + await input.press('u'); + await input.press('r'); + + // Then + expect(inputEvent).toHaveReceivedEventTimes(7); + }); + it('Should fire wcsChange event when user commit change with blur (tab)', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const input = await page.find('wcs-input'); + const changeEvent = await page.spyOnEvent('wcsChange'); + + // When + await input.click(); + await input.press('B'); + await input.press('l'); + await input.press('u'); + await input.press('r'); + await input.press('Tab'); + + await page.waitForChanges(); + + // Then + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); + }); + it('Should fire wcsChange event when user commit change with blur (click)', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const input = await page.find('wcs-input'); + const button = await page.find('button'); + const changeEvent = await page.spyOnEvent('wcsChange'); + + // When + await input.click(); + await input.press('B'); + await input.press('l'); + await input.press('u'); + await input.press('r'); + await button.focus(); + + // Then + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); + }); + it('Should fire wcsChange event when user commit change with enter', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const input = await page.find('wcs-input'); + const changeEvent = await page.spyOnEvent('wcsChange'); + + // When + await input.click(); + await input.press('E'); + await input.press('n'); + await input.press('t'); + await input.press('e'); + await input.press('r'); + await input.press('Enter'); + + // Then + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(changeEvent).toHaveReceivedEventDetail({ value: 'Enter' }); + }); + it('Should not fire wcsChange event when value is programmatically set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const input = await page.find('wcs-input'); + const changeEvent = await page.spyOnEvent('wcsChange'); + + // When + input.setProperty('value', 'Programmatically set value'); + + // Then + expect(changeEvent).toHaveReceivedEventTimes(0); + }); + it('Should not fire wcsInput event when value is programmatically set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const input = await page.find('wcs-input'); + const inputEvent = await page.spyOnEvent('wcsInput'); + + // When + input.setProperty('value', 'Programmatically set value'); + + // Then + expect(inputEvent).toHaveReceivedEventTimes(0); + }); + }); + + it('Should have a default value when value attribute is set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const input = await page.find('wcs-input'); + + // Then + expect(await input.getProperty('value')).toBe('Default value'); + expect(await (await page.find('wcs-input >>> input')).getProperty('value')).toBe('Default value'); + }); + + it('Should have a default value when value property is set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const input = await page.find('wcs-input'); + + // When + input.setProperty('value', 'Default value'); + await page.waitForChanges(); + + // Then expect(await input.getProperty('value')).toBe('Default value'); + expect(await (await page.find('wcs-input >>> input')).getProperty('value')).toBe('Default value'); + }); + + describe('Form Integration', () => { + it('should reflect validity from native input to form-associated custom element for required field', async () => { + // Given + const page = await newE2EPage(); + 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 = await page.find('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); + }); + + it('should include input value in form submission', async () => { + // Given + const page = await newE2EPage(); + 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['submittedData'] = { + hasInput: formData.has('test-input'), + inputValue: formData.get('test-input') + }; + }); + }); + + // When - submit form with empty input + const submitBtn = await page.find('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - input value should be included in form submission (empty string) + const emptyResult = await page.evaluate(() => window['submittedData']); + expect(emptyResult.hasInput).toBe(true); + expect(emptyResult.inputValue).toBe(''); + + // When - fill the input and submit again + const input = await page.find('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['submittedData']); + expect(filledResult.hasInput).toBe(true); + expect(filledResult.inputValue).toBe('Test value'); + }); + + it('should validate input against pattern attribute', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` +
+ + +
+ `); + + // Set up validity tracking + await page.evaluate(() => { + window['checkValidity'] = () => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }; + }); + + // When - fill the input with invalid email + const input = await page.find('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['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['checkValidity']()); + expect(validCheck).toBe(true); + }); + + it('should validate min/max length attributes', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` +
+ +
+ `); + + // Set up validity tracking + await page.evaluate(() => { + window['checkValidity'] = () => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }; + }); + + // When - fill the input with a value too short + const input = await page.find('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['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['checkValidity']()); + expect(validCheck).toBe(true); + }); + }); +}); diff --git a/src/components/input/input.scss b/src/components/input/input.scss index 3f8f96cd..7312c1ed 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 9b7c1c00..f2cc6379 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/readme.md b/src/components/radio/readme.md index 14d66045..392759f5 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/textarea/textarea.e2e.ts b/src/components/textarea/textarea.e2e.ts new file mode 100644 index 00000000..e2e02f9c --- /dev/null +++ b/src/components/textarea/textarea.e2e.ts @@ -0,0 +1,286 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { setWcsContent } from "../../utils/tests"; + +describe('Textarea component', () => { + describe('Events', () => { + it('Should fire wcsInput event once when user typing one char', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const textarea = await page.find('wcs-textarea'); + const inputEvent = await page.spyOnEvent('wcsInput'); + + // When + await textarea.click(); + await textarea.press('B'); + + // Then + expect(inputEvent).toHaveReceivedEventTimes(1); + }); + + + it('Should fire wcsInput event multiple times when user typing multiple chars', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const textarea = await page.find('wcs-textarea'); + const inputEvent = await page.spyOnEvent('wcsInput'); + + // When + await textarea.click(); + await textarea.press('B'); + await textarea.press('o'); + await textarea.press('n'); + await textarea.press('j'); + await textarea.press('o'); + await textarea.press('u'); + await textarea.press('r'); + + // Then + expect(inputEvent).toHaveReceivedEventTimes(7); + }); + it('Should fire wcsChange event when user commit change with blur (tab)', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const textarea = await page.find('wcs-textarea'); + const changeEvent = await page.spyOnEvent('wcsChange'); + + // When + await textarea.click(); + await textarea.press('B'); + await textarea.press('l'); + await textarea.press('u'); + await textarea.press('r'); + await textarea.press('Tab'); + + await page.waitForChanges(); + + // Then + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); + }); + it('Should fire wcsChange event when user commit change with blur (click)', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const textarea = await page.find('wcs-textarea'); + const button = await page.find('button'); + const changeEvent = await page.spyOnEvent('wcsChange'); + + // When + await textarea.click(); + await textarea.press('B'); + await textarea.press('l'); + await textarea.press('u'); + await textarea.press('r'); + await button.focus(); + + // Then + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); + }); + it('Should not fire wcsChange event when value is programmatically set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const textarea = await page.find('wcs-textarea'); + const changeEvent = await page.spyOnEvent('wcsChange'); + + // When + textarea.setProperty('value', 'Programmatically set value'); + + // Then + expect(changeEvent).toHaveReceivedEventTimes(0); + }); + it('Should not fire wcsInput event when value is programmatically set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const textarea = await page.find('wcs-textarea'); + const inputEvent = await page.spyOnEvent('wcsInput'); + + // When + textarea.setProperty('value', 'Programmatically set value'); + + // Then + expect(inputEvent).toHaveReceivedEventTimes(0); + }); + }); + + it('Should have a default value when value attribute is set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const textarea = await page.find('wcs-textarea'); + + // Then + expect(await textarea.getProperty('value')).toBe('Default value'); + expect(await (await page.find('wcs-textarea >>> textarea')).getProperty('value')).toBe('Default value'); + }); + + it('Should have a default value when value property is set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const textarea = await page.find('wcs-textarea'); + + // When + textarea.setProperty('value', 'Default value'); + await page.waitForChanges(); + + // Then + expect(await textarea.getProperty('value')).toBe('Default value'); + expect(await (await page.find('wcs-textarea >>> textarea')).getProperty('value')).toBe('Default value'); + }); + + describe('Form Integration', () => { + it('should reflect validity from native textarea to form-associated custom element for required field', async () => { + // Given + const page = await newE2EPage(); + 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 = await page.find('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); + }); + + it('should include textarea value in form submission', async () => { + // Given + const page = await newE2EPage(); + 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['submittedData'] = { + hasTextarea: formData.has('test-textarea'), + textareaValue: formData.get('test-textarea') + }; + }); + }); + + // When - submit form with empty textarea + const submitBtn = await page.find('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - textarea value should be included in form submission (empty string) + const emptyResult = await page.evaluate(() => window['submittedData']); + expect(emptyResult.hasTextarea).toBe(true); + expect(emptyResult.textareaValue).toBe(''); + + // When - fill the textarea and submit again + const textarea = await page.find('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['submittedData']); + expect(filledResult.hasTextarea).toBe(true); + expect(filledResult.textareaValue).toBe('Test value'); + }); + + it('should validate min/max length attributes', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` +
+ +
+ `); + + // Set up validity tracking + await page.evaluate(() => { + window['checkValidity'] = () => { + const form = document.getElementById('test-form') as HTMLFormElement; + return form.checkValidity(); + }; + }); + + // When - fill the textarea with a value too short + const textarea = await page.find('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['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['checkValidity']()); + expect(validCheck).toBe(true); + }); + }); +}); diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 287d38bb..f6cc95f5 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 58e9d419..6cc3ed84 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 0774843a..01a0446a 100644 --- a/stories/components/form-field/form-field.stories.ts +++ b/stories/components/form-field/form-field.stories.ts @@ -54,13 +54,29 @@ 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 + + + + + + + + + + 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 7a211677..9e829831 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' + } +} -- GitLab From 836eb42789b128427d91b884cffa55613df246b9 Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Mon, 28 Apr 2025 16:39:29 +0200 Subject: [PATCH 3/7] feat: form-associated select --- src/components/select/select.e2e.ts | 1344 +++++++++++++++++ src/components/select/select.tsx | 16 +- .../form-field/form-field.stories.ts | 10 +- 3 files changed, 1364 insertions(+), 6 deletions(-) create mode 100644 src/components/select/select.e2e.ts diff --git a/src/components/select/select.e2e.ts b/src/components/select/select.e2e.ts new file mode 100644 index 00000000..f70fa1bd --- /dev/null +++ b/src/components/select/select.e2e.ts @@ -0,0 +1,1344 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { setWcsContent } from "../../utils/tests"; + +describe('Select component', () => { + it('Expands when clicked', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + + // When + await select.click(); + + // Then + expect(select).toHaveClass('expanded'); + }); + + it('Expands using the open method', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + + // When + await select.callMethod('open'); + await page.waitForChanges(); + + // Then + expect(select).toHaveClass('expanded'); + }); + + it('Closes using the open method', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + + // When + await select.click(); + await select.callMethod('close'); + await page.waitForChanges(); + + // Then + expect(select).not.toHaveClass('expanded'); + }); + + it('Closes when user click outside', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + +
+ `); + const select = await page.find('wcs-select'); + + // When + await select.click(); + // XXX: Page.click() doesn't work + await page.$eval('div.outside', (elem: HTMLDivElement) => elem.click()); + await page.waitForChanges(); + + // Then + expect(select).not.toHaveClass('expanded'); + }); + + it('Closes when user click on another select', async () => { + // Given + const page = await newE2EPage(); + await page.setViewport({width: 1024, height: 1600}); + await setWcsContent(page, ` +
+ + One + Two + Three + + + One + Two + Three + +
+ `); + const select1 = await page.find('#select-1'); + const select2 = await page.find('#select-2'); + + // When + await select1.click(); + await page.waitForChanges(); + + // Then + expect(select1).toHaveClass('expanded'); + + await select2.click(); // select another select component in page + expect(select1).not.toHaveClass('expanded'); + expect(select2).toHaveClass('expanded'); + }); + + it('Let us select a value and fire event correctly', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + const firstSelectOption = await page.find('wcs-select > wcs-select-option'); + const label = await page.find('wcs-select >>> label'); + const changeSpy = await select.spyOnEvent('wcsChange'); + + // When + await select.click(); + await firstSelectOption.click(); + await page.waitForChanges(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({ value: '1' }); + expect(label.innerText).toBe('One'); + }); + + describe('select event', () => { + it('should not emit event if we set the value in js', async () => { + // Given + const page = await newE2EPage({ + html: ` + + One + Two + + ` + }); + const select = await page.find('wcs-select'); + const changeSpy = await select.spyOnEvent('wcsChange'); + + // When + await select.setProperty('value', '2'); + await page.waitForChanges(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(0); + }); + }); + + describe('setSelectedValue', () => { + it('Let user change selected value programmatically', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + + `); + const select = await page.find('wcs-select'); + + // When + await select.setProperty('value', '2'); + await page.waitForChanges(); + + // Then + expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two"); + }); + + it('Let user change selected values programmatically', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + Three + + `); + const select = await page.find('wcs-select'); + + // When + const newValue = ['2', '3']; + await select.setProperty('value', newValue); + await page.waitForChanges(); + + // Then + expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two, Three"); + }); + }); + + + it('Is focusable', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + + // When + await select.focus(); + const focusedEl = await page.find('wcs-select:focus'); + + // Then + expect(focusedEl).toBeDefined(); + }); + + it('[Autocomplete] Input field is focusable', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + + // When + await select.focus(); + const focusedEl = await page.find('wcs-select > input.autocomplete-field:focus'); + + // Then + expect(focusedEl).toBeDefined(); + }) + + it('[Autocomplete] filter is cleared when the select value is set to a falsy value', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + Three + Four + + `); + const select = await page.find('wcs-select'); + + // When + await select.click(); + + await page.keyboard.type('One'); + const firstSelectOption = await page.find('wcs-select > wcs-select-option'); + + await firstSelectOption.click(); + await page.waitForChanges(); + + select.setProperty('value', ''); + await page.waitForChanges(); + + // Then + await select.click(); + const availableOptions = await page.findAll('wcs-select > *:not([hidden])'); + // We check that all options are available to ensure the filter is no longer active + expect(availableOptions.length).toBe(4); + }); + + it('[Autocomplete] should not opened when initial value is set', async () => { + // Given - When + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + Three + Four + + `); + const select = await page.find('wcs-select'); + + // Then + expect(select).not.toHaveClass('expanded'); + }); + + it('[Autocomplete] should not opened when set value programmatically', async () => { + // Given - When + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + Three + Four + + `); + const select = await page.find('wcs-select'); + select.setProperty('value', '1'); + await page.waitForChanges(); + + // Then + expect(select).not.toHaveClass('expanded'); + }) + + it('[Autocomplete] should opened when set value with user interaction', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + Three + Four + + `); + const select = await page.find('wcs-select'); + const autocompleteInput = await page.find('wcs-select >>> input.autocomplete-field'); + + // When + await autocompleteInput.focus(); + await page.keyboard.type('O'); + await page.waitForChanges(); + + // Then + expect(select).toHaveClass('expanded'); + }) + + it(`Propagate wcsSelectChangeEvent when a new value is selected`, async () => { + // Given + const page = await newE2EPage({ + html: ` + + One + + ` + }); + const select = await page.find('wcs-select'); + const opt = await page.find('wcs-select > wcs-select-option'); + const changeSpy = await select.spyOnEvent('wcsChange'); + // When + await select.click(); + await opt.click(); + await page.waitForChanges(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({ value: '1' }); + }); + + xit(`Can have pre-selected option`, async () => { + // TODO: + }); + + describe('Disabled', () => { + it('Must not expand when disabled', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + + // When + await select.click(); + + // Then + expect(select).not.toHaveClass('expanded'); + }); + + it('Is not focusable when disabled', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + + // When + await select.focus(); + const focusedEl = await page.find('wcs-select:focus'); + + // Then + expect(focusedEl).toBeNull(); + }); + }); + + describe('Options', () => { + it('Adds selected attribute to selected option', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + const option = await page.find('wcs-select > wcs-select-option'); + + // When + await select.click(); + await option.click(); + await page.waitForChanges(); + + // Then + expect(option).toHaveAttribute('selected'); + }); + + it(`Removes selected attribute from previously selected options`, async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + + `); + const select = await page.find('wcs-select'); + const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); + + // When + await select.click(); + await opt1.click(); + await select.click(); // As it is not multiple we need to open it once again + await opt2.click(); + await page.waitForChanges(); + + // Then + expect(opt1).not.toHaveAttribute('selected'); + expect(opt2).toHaveAttribute('selected'); + }); + + it(`Must not let a user select a disabled option`, async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + const option = await page.find('wcs-select > wcs-select-option'); + + // When + await select.click(); + await option.click(); + await page.waitForChanges(); + + // Then + expect(select).not.toHaveAttribute('value'); + }); + }); + + describe('Multiple', () => { + it(`Musn't close when we select a value`, async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const select = await page.find('wcs-select'); + const firstSelectOption = await page.find('wcs-select > wcs-select-option'); + + // When + await select.click(); + await firstSelectOption.click(); + await page.waitForChanges(); + + // Then + expect(select).toHaveClass('expanded'); + }); + + it(`Allows to select multiple values`, async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + + `); + const select = await page.find('wcs-select'); + const changeSpy = await select.spyOnEvent('wcsChange'); + const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); + + // When + await select.click(); + await opt1.click(); + await opt2.click(); + await page.waitForChanges(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(2); + expect(changeSpy).toHaveReceivedEventDetail({ value: ['1', '2'] }); + }); + + + it('Allows to unselect a value', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + Three + + `); + const select = await page.find('wcs-select'); + const changeSpy = await select.spyOnEvent('wcsChange'); + const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); + + // When + await select.click(); + await opt1.click(); + await opt2.click(); + await opt1.click(); + await page.waitForChanges(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(3); + expect(changeSpy).toHaveReceivedEventDetail({ value: ['2'] }); + }); + it(`Displays all values separated by a comma`, async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + Three + + `); + const select = await page.find('wcs-select'); + const [opt1, opt2, opt3] = (await page.findAll('wcs-select > wcs-select-option')); + const label = await page.find('wcs-select >>> label'); + + // When + await select.click(); + await opt1.click(); + await opt2.click(); + await opt3.click(); + await page.waitForChanges(); + + // Then + expect(label.innerText).toEqual('One, Two, Three'); + }); + + it(`Tells the option that they should display as multiple`, async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + + `); + const option = await page.find('wcs-select > wcs-select-option'); + + // When + await page.waitForChanges(); + + // Then + expect(option).toHaveAttribute('multiple'); + }); + + it(`Propagate event when values are select`, async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + One + Two + + `); + const select = await page.find('wcs-select'); + const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); + const changeSpy = await select.spyOnEvent('wcsChange'); + + // When + await select.click(); + await opt1.click(); + await opt2.click(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(2); + expect(changeSpy).toHaveReceivedEventDetail({ value: ['1', '2'] }); + }); + }); + + describe('Keyboard navigation when select is closed and not multiple', () => { + let page; + let wcsSelect; + + beforeEach(async () => { + // Given + page = await newE2EPage(); + await setWcsContent(page, ` + + Option 1 + Option 2 + Option 3 + + `); + wcsSelect = await page.find('wcs-select'); + }); + + it('select value of first option enabled on Down Arrow key pressed', async () => { + // Given + const firstDisplayedValueOfEnableOption = "Option 2"; + const firstValueOfEnableOption = "option2"; + const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); + const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); + + // When + await wcsSelect.focus(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + // Then + expect(displayValue.innerText).toEqual(firstDisplayedValueOfEnableOption); + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({ value: firstValueOfEnableOption }); + }); + it('select value of last option enabled on PageDown key pressed', async () => { + // Given + const lastValueOfEnableOption = "Option 3"; + const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); + const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); + + // When + await wcsSelect.focus(); + await page.keyboard.press('PageDown'); + await page.waitForChanges(); + + // Then + expect(displayValue.innerText).toEqual(lastValueOfEnableOption); + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({ value: 'option3' }); + }); + it('select value of first option enabled on PageUp key pressed', async () => { + // Given + const fistDisplayedValueOfEnableOption = "Option 2"; + const firstValueOfEnableOption = "option2"; + const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); + const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); + + // When + await wcsSelect.focus(); + await page.keyboard.press('PageUp'); + await page.waitForChanges(); + + // Then + expect(displayValue.innerText).toEqual(fistDisplayedValueOfEnableOption); + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({ value: firstValueOfEnableOption }); + }); + it('open the overlay on Enter key press', async () => { + // When + await wcsSelect.focus(); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).toHaveClass("expanded"); + }); + it('open the overlay on Alt + Down Arrow key pressed', async () => { + // When + await wcsSelect.focus(); + await page.keyboard.down('Alt'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Alt'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).toHaveClass("expanded"); + + }); + + it('focuses last selected option when opening with keyboard after programmatic value change', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + Option 1 + Option 2 + Option 3 + + `); + const select = await page.find('wcs-select'); + + // When + await select.setProperty('value', 'option2'); + await page.waitForChanges(); + await select.focus(); + await page.keyboard.press('Enter'); // Open select with keyboard + await page.waitForChanges(); + + // Then + const focusedOption = await page.find('wcs-select-option:focus'); + expect(focusedOption.getAttribute('value')).toBe('option2'); + }); + }); + describe('Keyboard navigation when select is opened and not multiple', () => { + let page; + let wcsSelect; + let selectOptions; + + beforeEach(async () => { + // Given + page = await newE2EPage(); + await setWcsContent(page, ` + + Option 1 + Option 2 + Option 3 + + `); + wcsSelect = await page.find('wcs-select'); + selectOptions = await page.findAll('wcs-select > wcs-select-option'); + // Open by default the overlay for each test + await wcsSelect.focus(); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + }); + + it('close the overlay on Escape key pressed and focus select control', async () => { + // Given + // The overlay is open (makes in beforeEach) + + // When + await page.keyboard.press('Escape'); + const focusedEl = await page.find('wcs-select:focus'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + expect(focusedEl).toBeDefined(); + }); + + it('close the overlay on Alt + ArrowUp keys pressed and focus select control', async () => { + // Given + // The overlay is open (makes in beforeEach) + + // When + await page.keyboard.down('Alt'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.up('Alt'); + const focusedEl = await page.find('wcs-select:focus'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + expect(focusedEl).toBeDefined(); + }); + it('close the overlay on Tab key pressed', async () => { + // Given + // The overlay is open (makes in beforeEach) + + // When + await page.keyboard.press('Tab'); + const focusedEl = await page.find('wcs-select:focus'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + expect(focusedEl).toBeDefined(); + }); + it('close the overlay on Tab + Shift keys pressed', async () => { + // Given + // The overlay is open (makes in beforeEach) + + // When + await page.keyboard.down('Shift'); + await page.keyboard.press('Tab'); + await page.keyboard.up('Shift'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + const focusedSelect = await page.find('wcs-select:focus'); + expect(focusedSelect).toBeDefined(); + }); + it('choose the current option on Enter key pressed', async () => { + // Given + // The overlay is open (makes in beforeEach) + const firstDisplayedValueOfEnableOption = "Option 2"; + const firstValueOfEnabledOption = "option2"; + const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); + const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); + + // When + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + // Then + expect(displayValue.innerText).toEqual(firstDisplayedValueOfEnableOption); + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({ value: firstValueOfEnabledOption }); + }); + it('move focus to next option on Down Arrow key down', async () => { + // Given + // The overlay is open (makes in beforeEach) + const nextValueOfEnableOption = selectOptions[2]; + const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); + + // When + await page.keyboard.press('ArrowDown'); + const focusedOption = await page.find('wcs-select-option:focus'); + + // Then + expect(focusedOption.value).toEqual(nextValueOfEnableOption.value); + expect(changeSpy).toHaveReceivedEventTimes(0) + }); + }); + describe('Keyboard navigation when select is closed and multiple', () => { + let page; + let wcsSelect; + let selectOptions; + + beforeEach(async () => { + // Given + page = await newE2EPage(); + await setWcsContent(page, ` + + Option 1 + Option 2 + Option 3 + + `); + wcsSelect = await page.find('wcs-select'); + selectOptions = await page.findAll('wcs-select > wcs-select-option'); + }); + + it('move focus into the first enabled option on Down Arrow key pressed', async () => { + // Given + const firstOptionEnabled = selectOptions[1]; + const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); + + // When + await wcsSelect.focus(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + // Then + const focusedOption = await page.find('wcs-select-option:focus'); + expect(focusedOption).toEqual(firstOptionEnabled); + expect(changeSpy).toHaveReceivedEventTimes(0) + }); + it('move focus into the first enabled option on Enter key pressed', async () => { + // Given + const firstOptionEnabled = selectOptions[1]; + const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); + + // When + await wcsSelect.focus(); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + // Then + const focusedOption = await page.find('wcs-select-option:focus'); + expect(focusedOption).toEqual(firstOptionEnabled); + expect(changeSpy).toHaveReceivedEventTimes(0) + }); + + it('focuses last selected option when opening with keyboard after programmatic value change', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + Option 1 + Option 2 + Option 3 + + `); + const select = await page.find('wcs-select'); + + // When + await select.setProperty('value', ['option1', 'option2']); + await page.waitForChanges(); + await select.focus(); + await page.keyboard.press('Enter'); // Open select with keyboard + await page.waitForChanges(); + + // Then + const focusedOption = await page.find('wcs-select-option:focus'); + expect(focusedOption.getAttribute('value')).toBe('option2'); // Should focus the last option in the array + }); + }); + describe('Keyboard navigation when select opened and multiple', () => { + let page; + let wcsSelect; + + beforeEach(async () => { + // Given + page = await newE2EPage(); + await setWcsContent(page, ` + + Option 1 + Option 2 + Option 3 + + `); + wcsSelect = await page.find('wcs-select'); + // Open by default the overlay for each test + await wcsSelect.focus(); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + }); + + it('close the overlay on Tab key pressed and not focus an checkbox', async () => { + // Given + // The overlay is open (makes in beforeEach) + + // When + await page.keyboard.press('Tab'); + const focusedEl = await page.find('wcs-select:focus'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + expect(focusedEl).toBeDefined(); + }); + }); + //region Autocomplete tests + describe('[Autocomplete] Keyboard navigation when select closed', () => { + let page; + let wcsSelect; + let selectOptions; + let wcsAutocompleteInput; + + beforeEach(async () => { + // Given + page = await newE2EPage(); + await setWcsContent(page, ` + + Option 1 + Option 2 + Option 3 + + `); + wcsSelect = await page.find('wcs-select'); + selectOptions = await page.findAll('wcs-select > wcs-select-option'); + wcsAutocompleteInput = await page.find('wcs-select >>> input.autocomplete-field'); + }); + + it('open listbox and move focus into the first enabled option on Arrow Down pressed', async () => { + const firstOptionEnabled = selectOptions[1]; + + // When + await wcsSelect.focus(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + // Then + const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); + expect(visuallyFocusedOption).toEqual(firstOptionEnabled); + expect(wcsSelect).toHaveClass("expanded"); + }); + + it('open listbox without moveing on Alt + Arrow Down pressed', async () => { + // When + await wcsSelect.focus(); + await page.keyboard.down('Alt'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Alt'); + await page.waitForChanges(); + + // Then + const anyVisuallyFocusedOption= await page.find('wcs-select-option[highlighted]'); + expect(wcsSelect).toHaveClass("expanded"); + expect(anyVisuallyFocusedOption).toBeNull(); + }); + it('clear textbox on Escape pressed', async () => { + // Given + await wcsAutocompleteInput.type('test'); + await page.waitForChanges(); + + // When + await wcsAutocompleteInput.click(); + await page.keyboard.press('Escape'); + await page.waitForChanges(); + + // Then + const value = await wcsAutocompleteInput.getProperty('value'); + expect(value).toEqual(''); + }); + }); + describe('[Autocomplete] Keyboard navigation when select expanded', () => { + let page; + let wcsSelect; + let selectOptions; + let autocompleteInput; + + beforeEach(async () => { + // Given + page = await newE2EPage(); + await setWcsContent(page, ` + + Apple + Banana + Peach + + `); + wcsSelect = await page.find('wcs-select'); + selectOptions = await page.findAll('wcs-select > wcs-select-option'); + autocompleteInput = await page.find('wcs-select >>> input.autocomplete-field'); + }); + + it('close listbox on Escape', async () => { + // When + await autocompleteInput.click(); + await page.keyboard.press('Escape'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + }); + it('stay opened on Enter when no option are highlighted', async () => { + // When + await autocompleteInput.click(); + await page.waitForChanges(); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).toHaveClass("expanded"); + }); + it('Close overlay when an highlighted option is selected with Enter keypress', async () => { + // When + await autocompleteInput.click(); + await page.waitForChanges(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + }); + it('focus last option on Arrow Up', async () => { + // Given + const lastOption = selectOptions[selectOptions.length - 1]; + + // When + await autocompleteInput.focus(); + await page.keyboard.press('ArrowUp'); + await page.waitForChanges(); + + // Then + const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); + expect(visuallyFocusedOption).toEqual(lastOption); + }); + it('focus first option on Arrow Down', async () => { + // Given + const firstOption = selectOptions[1]; // Because first option is disabled + + // When + await autocompleteInput.focus(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + // Then + const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); + expect(visuallyFocusedOption).toEqual(firstOption); + }); + it('replace text, close listbox, focus textbox on Enter', async () => { + // Given + const firstOption = selectOptions[1]; // Because first option is disabled + + // When + await autocompleteInput.focus(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + // Then + expect(await autocompleteInput.getProperty('value')).toEqual(firstOption.textContent); + expect(wcsSelect).not.toHaveClass("expanded"); + const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); + expect(focusedInput).toBeDefined(); + }); + it('close listbox, focus textbox on Escape', async () => { + // When + await page.keyboard.press('Escape'); + await page.waitForChanges(); + + // Then + expect(wcsSelect).not.toHaveClass("expanded"); + const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); + expect(focusedInput).toBeDefined(); + }); + it('cycle to next option when Arrow Down', async () => { + // Given + const firstSelectableOption = selectOptions[1]; + + // When + await wcsSelect.focus(); + await page.keyboard.press('ArrowDown'); // Going to option[1] + await page.keyboard.press('ArrowDown'); // Going to option[2] + await page.keyboard.press('ArrowDown'); // Going back to option[1] + await page.waitForChanges(); + + // Then + const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); + expect(visuallyFocusedOption).toEqual(firstSelectableOption); + }); + it('cycle to previous option when Arrow Up', async () => { + // Given + const lastSelectableOption = selectOptions[2]; + + // When + await wcsSelect.focus(); + await page.keyboard.press('ArrowUp'); // Going to option[2] + await page.keyboard.press('ArrowUp'); // Going to option[1] + await page.keyboard.press('ArrowUp'); // Going back to option[2] + await page.waitForChanges(); + + // Then + const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); + expect(visuallyFocusedOption).toEqual(lastSelectableOption); + }); + it('focus textbox, move cursor when Left or Right Arrow', async () => { + // Given + const typedText = 'test'; + + // When + await wcsSelect.focus(); + await autocompleteInput.type(typedText); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowRight'); + await page.waitForChanges(); + + // Then + const cursorPositionAfter = await autocompleteInput.getProperty('selectionStart'); + expect(cursorPositionAfter).toEqual(typedText.length); + const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); + expect(focusedInput).toBeDefined(); + }); + it('focus textbox, move cursor to the start of the text when Home pressed', async () => { + // Given + const typedText = 'test'; + + // When + await autocompleteInput.type(typedText); + await page.keyboard.press('Home'); + await page.waitForChanges(); + + // Then + const cursorPositionAfter = await autocompleteInput.getProperty('selectionStart'); + expect(cursorPositionAfter).toEqual(0); + const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); + expect(focusedInput).toBeDefined(); + }); + it('focus textbox, move cursor to the end of the text when End pressed', async () => { + // Given + const typedText = 'test'; + + // When + await autocompleteInput.press('t'); + await autocompleteInput.press('e'); + await autocompleteInput.press('s'); + await autocompleteInput.press('t'); + await page.keyboard.press('End'); + await page.waitForChanges(); + + // Then + const cursorPositionAfter = await autocompleteInput.getProperty('selectionStart'); + expect(cursorPositionAfter).toEqual(typedText.length); + const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); + expect(focusedInput).toBeDefined(); + }); + it('focus textbox, filter listbox, remove visual focus from listbox when any printable character', async () => { + // Given + const allOptions = await page.findAll('wcs-select-option'); + + // When + await wcsSelect.focus(); + await page.keyboard.press('a'); + await page.waitForChanges(); + + // Then + const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); + expect(focusedInput).toBeDefined(); // Focus textbox + const optionsWithFilter = await page.findAll('wcs-select-option:not([aria-hidden])'); + expect(optionsWithFilter.length).toBeLessThan(allOptions.length); // Filter listbox + const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); + expect(visuallyFocusedOption).toBeNull(); // Remove visual focus from listbox + }); + }); + //endregion + + describe('Form Integration', () => { + it('should reflect validity from select for required field', async () => { + // Given + const page = await newE2EPage(); + 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 = await page.find('wcs-select'); + const firstSelectOption = await page.find('wcs-select > wcs-select-option'); + 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); + }); + + it('should include select value in form submission', async () => { + // Given + const page = await newE2EPage(); + 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['submittedData'] = { + hasSelect: formData.has('test-select'), + selectValue: formData.get('test-select') + }; + }); + }); + + // When - submit form with empty select + const submitBtn = await page.find('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - select value should be included in form submission (empty string) + const emptyResult = await page.evaluate(() => window['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 = await page.find('wcs-select'); + const firstSelectOption = await page.find('wcs-select > wcs-select-option'); + 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['submittedData']); + expect(filledResult.hasSelect).toBe(true); + expect(filledResult.selectValue).toBe('1'); + }); + }); + + it('Should handle asynchronous options loading with an initial value and update the label', async () => { + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const select = await page.find('wcs-select'); + + expect(select.shadowRoot.querySelector('.wcs-select-value')).toBeNull(); // Verify initial value is displayed + + // Add options + await page.$eval('wcs-select', (el: HTMLElement) => { + el.innerHTML = ` + One + Two + Three + Four + `; + }); + await page.waitForChanges(); + + expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two"); // Verify initial value is displayed + }); + + it('[Multiple] Should handle asynchronous options loading with an initial value and update the label', async () => { + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const select = await page.find('wcs-select'); + select.setProperty('value', ['2', '3']); + await page.waitForChanges(); + + expect(select.shadowRoot.querySelector('.wcs-select-value')).toBeNull(); // Verify initial value is displayed + + // Add options + await page.$eval('wcs-select', (el: HTMLElement) => { + el.innerHTML = ` + One + Two + Three + Four + `; + }); + await page.waitForChanges(); + + expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two, Three"); // Verify initial value is displayed + }); + +}); + diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index d3294902..37a96d62 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/stories/components/form-field/form-field.stories.ts b/stories/components/form-field/form-field.stories.ts index 01a0446a..d8ac0a03 100644 --- a/stories/components/form-field/form-field.stories.ts +++ b/stories/components/form-field/form-field.stories.ts @@ -64,11 +64,11 @@ const InputTemplate: StoryFn> = (args) => html` 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? -- GitLab From 6238e371f357aeb664ccd2ee38f48a23803f3fc4 Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Mon, 28 Apr 2025 16:49:31 +0200 Subject: [PATCH 4/7] feat: form-associated switch and add required attribute --- CHANGELOG.md | 1 + .../projects/wcs-angular/src/lib/proxies.ts | 4 +- custom-elements.json | 410 ++++++++++-------- src/components.d.ts | 8 + src/components/switch/readme.md | 13 +- src/components/switch/switch.e2e.ts | 131 ++++++ src/components/switch/switch.tsx | 39 +- .../form-field/form-field.stories.ts | 3 + 8 files changed, 412 insertions(+), 197 deletions(-) create mode 100644 src/components/switch/switch.e2e.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0abdc780..9761a188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **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 f7c782f3..042028a6 100644 --- a/angular/projects/wcs-angular/src/lib/proxies.ts +++ b/angular/projects/wcs-angular/src/lib/proxies.ts @@ -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 d6e263ad..5a253c01 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 66f57f72..8205c79e 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -2262,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; } /** @@ -7030,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/switch/readme.md b/src/components/switch/readme.md index 5c4c983f..fe97c058 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.ts b/src/components/switch/switch.e2e.ts new file mode 100644 index 00000000..fba86a76 --- /dev/null +++ b/src/components/switch/switch.e2e.ts @@ -0,0 +1,131 @@ +import { newE2EPage } from "@stencil/core/testing"; +import { setWcsContent } from "../../utils/tests"; + +describe('Switch component', () => { + describe('Events', () => { + it('should emit a wcsChange event when clicked on wcs-switch', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + Switch + + `); + + // When + const switchElement = await page.find('wcs-switch'); + const eventSpy = await switchElement.spyOnEvent('wcsChange'); + + await switchElement.click(); + await page.waitForChanges(); + + // Then + expect(eventSpy) + .toHaveReceivedEventDetail({ + checked: true + }); + }); + + it('should emit a wcsChange event when space pressed on wcs-switch', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + Switch + + `); + + // When + const switchElement = await page.find('wcs-switch'); + const eventSpy = await switchElement.spyOnEvent('wcsChange'); + + await switchElement.press('Space'); + await page.waitForChanges(); + + // Then + expect(eventSpy) + .toHaveReceivedEventDetail({ + checked: true + }); + }); + }); + describe('Form Integration', () => { + it('should reflect validity from native input to form-associated custom element', async () => { + // Given + const page = await newE2EPage(); + 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 = await page.find('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); + }); + + it('should include switch value in form submission when checked', async () => { + // Given + const page = await newE2EPage(); + 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['submittedData'] = { + hasSwitch: formData.has('test-switch'), + switchValue: formData.get('test-switch') + }; + }); + }); + + // When - submit form with unchecked switch + const submitBtn = await page.find('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - switch value should not be included in form submission + const uncheckedResult = await page.evaluate(() => window['submittedData']); + expect(uncheckedResult.hasSwitch).toBe(false); + + // When - check the switch and submit again + const switchElement = await page.find('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['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 4a776023..f0c889b2 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/stories/components/form-field/form-field.stories.ts b/stories/components/form-field/form-field.stories.ts index d8ac0a03..d7276dc6 100644 --- a/stories/components/form-field/form-field.stories.ts +++ b/stories/components/form-field/form-field.stories.ts @@ -70,6 +70,9 @@ const InputTemplate: StoryFn> = (args) => html` Japan
+ + + What do you think about the fact that you are filling a fake form? -- GitLab From fbba68c7fc25772052afa30d73157167f4cd74be Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Mon, 28 Apr 2025 16:55:47 +0200 Subject: [PATCH 5/7] feat: form-associated counter --- src/components/counter/counter.e2e.ts | 253 ++++++++++++++++++++++++++ src/components/counter/counter.tsx | 5 + src/components/counter/readme.md | 1 + 3 files changed, 259 insertions(+) create mode 100644 src/components/counter/counter.e2e.ts diff --git a/src/components/counter/counter.e2e.ts b/src/components/counter/counter.e2e.ts new file mode 100644 index 00000000..d1b3e9b7 --- /dev/null +++ b/src/components/counter/counter.e2e.ts @@ -0,0 +1,253 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { setWcsContent } from "../../utils/tests"; + + +describe('counter', () => { + it('should increment the counter when click on plus button', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const counter = await page.find('wcs-counter'); + const changeSpy = await counter.spyOnEvent('wcsChange'); + const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); + // When + await incrementButton.click(); + await page.waitForChanges(); + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({value: 1}); + }); + + it('should decrement the counter when click on minus button', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const counter = await page.find('wcs-counter'); + const changeSpy = await counter.spyOnEvent('wcsChange'); + const incrementButton = await page.find('wcs-counter >>> wcs-button:first-child'); + // When + await incrementButton.click(); + await page.waitForChanges(); + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({value: -1}); + }); + + it('should have 0 as default value', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); + + // Then + expect(currentDisplayedValue).toEqualText('0'); + }); + it('should respect the step attribute', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const counter = await page.find('wcs-counter'); + const changeSpy = await counter.spyOnEvent('wcsChange'); + const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); + // When + await incrementButton.click(); + await page.waitForChanges(); + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({value: 5}); + }); + it('should respect the min attribute', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const counter = await page.find('wcs-counter'); + const changeSpy = await counter.spyOnEvent('wcsChange'); + const decrementButton = await page.find('wcs-counter >>> wcs-button:first-child'); + // When + await decrementButton.click(); + await decrementButton.click(); + await page.waitForChanges(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({value: -1}); + }); + it('should use the min attribute as default value when value is not set', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); + // Then + expect(currentDisplayedValue).toEqualText('5'); + }); + it('should respect the max attribute', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const counter = await page.find('wcs-counter'); + const changeSpy = await counter.spyOnEvent('wcsChange'); + const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); + // When + await incrementButton.click(); + await incrementButton.click(); + await page.waitForChanges(); + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy).toHaveReceivedEventDetail({value: 1}); + }); + it('should respect the value attribute', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); + // Then + expect(currentDisplayedValue).toEqualText('5'); + }); + it('should use the min value as default when min is greater than 0', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); + // Then + expect(currentDisplayedValue).toEqualText('5'); + }); + it('should use the min value as default when min is greater than value', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); + // Then + expect(currentDisplayedValue).toEqualText('5'); + }); + it('should fire wcsBlur event when the counter loose focus', async () => { + const page = await newE2EPage(); + await setWcsContent(page, ` + + + + `); + const counter = await page.find('wcs-counter'); + const blurSpy = await counter.spyOnEvent('wcsBlur'); + const firstButton = await page.find('#first'); + // When + await firstButton.click(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + // Then + expect(blurSpy).toHaveReceivedEventTimes(1); + }); + it('should fire wcsBlur event when the user click on decrement button and leave', async () => { + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const counter = await page.find('wcs-counter'); + const button = await page.find('button'); + const blurSpy = await counter.spyOnEvent('wcsBlur'); + const decrementButton = await page.find('wcs-counter >>> wcs-button:first-child'); + // When + await decrementButton.click(); + await button.click(); + await page.waitForChanges(); + + // Then + expect(blurSpy).toHaveReceivedEventTimes(1); + }); + it('should fire wcsBlur event when the user click on increment button and leave', async () => { + const page = await newE2EPage(); + await setWcsContent(page, ` + + + `); + const counter = await page.find('wcs-counter'); + const button = await page.find('button'); + const blurSpy = await counter.spyOnEvent('wcsBlur'); + const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); + // When + await incrementButton.click(); + await button.click(); + await page.waitForChanges(); + + // Then + expect(blurSpy).toHaveReceivedEventTimes(1); + }); + it('should not be interactive when disabled', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + `); + const counter = await page.find('wcs-counter'); + const changeSpy = await counter.spyOnEvent('wcsChange'); + const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); + + // When + await incrementButton.click(); + await page.waitForChanges(); + + // Then + expect(changeSpy).not.toHaveReceivedEvent(); + expect(await counter.getProperty('value')).toBe(0); + expect(await incrementButton.getProperty('disabled')).toBe(true); + }); + + describe('Form Integration', () => { + it('should include counter value in form submission when checked', async () => { + // Given + const page = await newE2EPage(); + 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['submittedData'] = { + hasCounter: formData.has('test-counter'), + counterValue: formData.get('test-counter') + }; + }); + }); + + // When - submit form with unchecked counter + const submitBtn = await page.find('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - counter value should not be included in form submission + const result = await page.evaluate(() => window['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 68f40b34..d1439be5 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 684218ab..a009c2b5 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` | -- GitLab From 4d59a30f10b6320b4d172ee69a5bb5b7539e26fe Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Mon, 19 May 2025 10:52:09 +0200 Subject: [PATCH 6/7] feat: form-associated checkbox --- src/components/checkbox/checkbox.e2e.ts | 132 ++++++++++++++++++++++++ src/components/checkbox/checkbox.tsx | 32 +++++- 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/components/checkbox/checkbox.e2e.ts diff --git a/src/components/checkbox/checkbox.e2e.ts b/src/components/checkbox/checkbox.e2e.ts new file mode 100644 index 00000000..242505e3 --- /dev/null +++ b/src/components/checkbox/checkbox.e2e.ts @@ -0,0 +1,132 @@ +import { newE2EPage } from "@stencil/core/testing"; +import { setWcsContent } from "../../utils/tests"; + +describe('Checkbox component', () => { + describe('Events', () => { + it('should emit a wcsChange event when clicked on wcs-checkbox', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + Checkbox + + `); + + // When + const checkbox = await page.find('wcs-checkbox'); + const eventSpy = await checkbox.spyOnEvent('wcsChange'); + + await checkbox.click(); + await page.waitForChanges(); + + // Then + expect(eventSpy) + .toHaveReceivedEventDetail({ + checked: true + }); + }); + + it('should emit a wcsChange event when space pressed on wcs-checkbox', async () => { + // Given + const page = await newE2EPage(); + await setWcsContent(page, ` + + Checkbox + + `); + + // When + const checkbox = await page.find('wcs-checkbox'); + const eventSpy = await checkbox.spyOnEvent('wcsChange'); + + await checkbox.press('Space'); + await page.waitForChanges(); + + // Then + expect(eventSpy) + .toHaveReceivedEventDetail({ + checked: true + }); + }); + }); + + describe('Form Integration', () => { + it('should reflect validity from native input to form-associated custom element', async () => { + // Given + const page = await newE2EPage(); + 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 = await page.find('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); + }); + + it('should include checkbox value in form submission when checked', async () => { + // Given + const page = await newE2EPage(); + 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['submittedData'] = { + hasCheckbox: formData.has('test-checkbox'), + checkboxValue: formData.get('test-checkbox') + }; + }); + }); + + // When - submit form with unchecked checkbox + const submitBtn = await page.find('button'); + await submitBtn.click(); + await page.waitForChanges(); + + // Then - checkbox value should not be included in form submission + const uncheckedResult = await page.evaluate(() => window['submittedData']); + expect(uncheckedResult.hasCheckbox).toBe(false); + + // When - check the checkbox and submit again + const checkbox = await page.find('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['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 99e1b3c2..0d866a23 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} -- GitLab From 5b074a0a5e60a6577c008a9010519cacc081a866 Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Thu, 18 Dec 2025 17:49:39 +0100 Subject: [PATCH 7/7] wip puppeteer to playwright --- .../checkbox/checkbox.e2e.playwright.ts | 78 + src/components/checkbox/checkbox.e2e.ts | 132 -- .../counter/counter.e2e.playwright.ts | 36 + src/components/counter/counter.e2e.ts | 253 ---- src/components/input/input.e2e.playwright.ts | 174 +++ src/components/input/input.e2e.ts | 351 ----- .../select/select.e2e.playwright.ts | 93 ++ src/components/select/select.e2e.ts | 1344 ----------------- .../switch/switch.e2e.playwright.ts | 78 + src/components/switch/switch.e2e.ts | 131 -- .../textarea/textarea.e2e.playwright.ts | 128 ++ src/components/textarea/textarea.e2e.ts | 286 ---- 12 files changed, 587 insertions(+), 2497 deletions(-) delete mode 100644 src/components/checkbox/checkbox.e2e.ts delete mode 100644 src/components/counter/counter.e2e.ts delete mode 100644 src/components/input/input.e2e.ts delete mode 100644 src/components/select/select.e2e.ts delete mode 100644 src/components/switch/switch.e2e.ts delete mode 100644 src/components/textarea/textarea.e2e.ts diff --git a/src/components/checkbox/checkbox.e2e.playwright.ts b/src/components/checkbox/checkbox.e2e.playwright.ts index 7bf112f6..65b5e39f 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.e2e.ts b/src/components/checkbox/checkbox.e2e.ts deleted file mode 100644 index 242505e3..00000000 --- a/src/components/checkbox/checkbox.e2e.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { newE2EPage } from "@stencil/core/testing"; -import { setWcsContent } from "../../utils/tests"; - -describe('Checkbox component', () => { - describe('Events', () => { - it('should emit a wcsChange event when clicked on wcs-checkbox', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - Checkbox - - `); - - // When - const checkbox = await page.find('wcs-checkbox'); - const eventSpy = await checkbox.spyOnEvent('wcsChange'); - - await checkbox.click(); - await page.waitForChanges(); - - // Then - expect(eventSpy) - .toHaveReceivedEventDetail({ - checked: true - }); - }); - - it('should emit a wcsChange event when space pressed on wcs-checkbox', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - Checkbox - - `); - - // When - const checkbox = await page.find('wcs-checkbox'); - const eventSpy = await checkbox.spyOnEvent('wcsChange'); - - await checkbox.press('Space'); - await page.waitForChanges(); - - // Then - expect(eventSpy) - .toHaveReceivedEventDetail({ - checked: true - }); - }); - }); - - describe('Form Integration', () => { - it('should reflect validity from native input to form-associated custom element', async () => { - // Given - const page = await newE2EPage(); - 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 = await page.find('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); - }); - - it('should include checkbox value in form submission when checked', async () => { - // Given - const page = await newE2EPage(); - 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['submittedData'] = { - hasCheckbox: formData.has('test-checkbox'), - checkboxValue: formData.get('test-checkbox') - }; - }); - }); - - // When - submit form with unchecked checkbox - const submitBtn = await page.find('button'); - await submitBtn.click(); - await page.waitForChanges(); - - // Then - checkbox value should not be included in form submission - const uncheckedResult = await page.evaluate(() => window['submittedData']); - expect(uncheckedResult.hasCheckbox).toBe(false); - - // When - check the checkbox and submit again - const checkbox = await page.find('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['submittedData']); - expect(checkedResult.hasCheckbox).toBe(true); - expect(checkedResult.checkboxValue).toBe('on'); - }); - }); -}); diff --git a/src/components/counter/counter.e2e.playwright.ts b/src/components/counter/counter.e2e.playwright.ts index 3ba69492..aaedaef7 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.e2e.ts b/src/components/counter/counter.e2e.ts deleted file mode 100644 index d1b3e9b7..00000000 --- a/src/components/counter/counter.e2e.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; -import { setWcsContent } from "../../utils/tests"; - - -describe('counter', () => { - it('should increment the counter when click on plus button', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const counter = await page.find('wcs-counter'); - const changeSpy = await counter.spyOnEvent('wcsChange'); - const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); - // When - await incrementButton.click(); - await page.waitForChanges(); - // Then - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({value: 1}); - }); - - it('should decrement the counter when click on minus button', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const counter = await page.find('wcs-counter'); - const changeSpy = await counter.spyOnEvent('wcsChange'); - const incrementButton = await page.find('wcs-counter >>> wcs-button:first-child'); - // When - await incrementButton.click(); - await page.waitForChanges(); - // Then - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({value: -1}); - }); - - it('should have 0 as default value', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); - - // Then - expect(currentDisplayedValue).toEqualText('0'); - }); - it('should respect the step attribute', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const counter = await page.find('wcs-counter'); - const changeSpy = await counter.spyOnEvent('wcsChange'); - const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); - // When - await incrementButton.click(); - await page.waitForChanges(); - // Then - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({value: 5}); - }); - it('should respect the min attribute', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const counter = await page.find('wcs-counter'); - const changeSpy = await counter.spyOnEvent('wcsChange'); - const decrementButton = await page.find('wcs-counter >>> wcs-button:first-child'); - // When - await decrementButton.click(); - await decrementButton.click(); - await page.waitForChanges(); - - // Then - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({value: -1}); - }); - it('should use the min attribute as default value when value is not set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); - // Then - expect(currentDisplayedValue).toEqualText('5'); - }); - it('should respect the max attribute', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const counter = await page.find('wcs-counter'); - const changeSpy = await counter.spyOnEvent('wcsChange'); - const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); - // When - await incrementButton.click(); - await incrementButton.click(); - await page.waitForChanges(); - // Then - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({value: 1}); - }); - it('should respect the value attribute', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); - // Then - expect(currentDisplayedValue).toEqualText('5'); - }); - it('should use the min value as default when min is greater than 0', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); - // Then - expect(currentDisplayedValue).toEqualText('5'); - }); - it('should use the min value as default when min is greater than value', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const currentDisplayedValue = await page.find('wcs-counter >>> .current-value'); - // Then - expect(currentDisplayedValue).toEqualText('5'); - }); - it('should fire wcsBlur event when the counter loose focus', async () => { - const page = await newE2EPage(); - await setWcsContent(page, ` - - - - `); - const counter = await page.find('wcs-counter'); - const blurSpy = await counter.spyOnEvent('wcsBlur'); - const firstButton = await page.find('#first'); - // When - await firstButton.click(); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.waitForChanges(); - - // Then - expect(blurSpy).toHaveReceivedEventTimes(1); - }); - it('should fire wcsBlur event when the user click on decrement button and leave', async () => { - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const counter = await page.find('wcs-counter'); - const button = await page.find('button'); - const blurSpy = await counter.spyOnEvent('wcsBlur'); - const decrementButton = await page.find('wcs-counter >>> wcs-button:first-child'); - // When - await decrementButton.click(); - await button.click(); - await page.waitForChanges(); - - // Then - expect(blurSpy).toHaveReceivedEventTimes(1); - }); - it('should fire wcsBlur event when the user click on increment button and leave', async () => { - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const counter = await page.find('wcs-counter'); - const button = await page.find('button'); - const blurSpy = await counter.spyOnEvent('wcsBlur'); - const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); - // When - await incrementButton.click(); - await button.click(); - await page.waitForChanges(); - - // Then - expect(blurSpy).toHaveReceivedEventTimes(1); - }); - it('should not be interactive when disabled', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const counter = await page.find('wcs-counter'); - const changeSpy = await counter.spyOnEvent('wcsChange'); - const incrementButton = await page.find('wcs-counter >>> wcs-button:last-child'); - - // When - await incrementButton.click(); - await page.waitForChanges(); - - // Then - expect(changeSpy).not.toHaveReceivedEvent(); - expect(await counter.getProperty('value')).toBe(0); - expect(await incrementButton.getProperty('disabled')).toBe(true); - }); - - describe('Form Integration', () => { - it('should include counter value in form submission when checked', async () => { - // Given - const page = await newE2EPage(); - 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['submittedData'] = { - hasCounter: formData.has('test-counter'), - counterValue: formData.get('test-counter') - }; - }); - }); - - // When - submit form with unchecked counter - const submitBtn = await page.find('button'); - await submitBtn.click(); - await page.waitForChanges(); - - // Then - counter value should not be included in form submission - const result = await page.evaluate(() => window['submittedData']); - expect(result.hasCounter).toBe(true); - expect(result.counterValue).toBe('0'); - }); - }); -}); diff --git a/src/components/input/input.e2e.playwright.ts b/src/components/input/input.e2e.playwright.ts index d1a85975..a6a1d000 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.e2e.ts b/src/components/input/input.e2e.ts deleted file mode 100644 index 4ba1d422..00000000 --- a/src/components/input/input.e2e.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; -import { setWcsContent } from "../../utils/tests"; - -describe('Input component', () => { - describe('Events', () => { - it('Should fire wcsInput event once when user typing one char', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const input = await page.find('wcs-input'); - const inputEvent = await page.spyOnEvent('wcsInput'); - - // When - await input.click(); - await input.press('B'); - - // Then - expect(inputEvent).toHaveReceivedEventTimes(1); - }); - it('Should fire wcsInput event multiple times when user typing multiple chars', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const input = await page.find('wcs-input'); - const inputEvent = await page.spyOnEvent('wcsInput'); - - // When - await input.click(); - await input.press('B'); - await input.press('o'); - await input.press('n'); - await input.press('j'); - await input.press('o'); - await input.press('u'); - await input.press('r'); - - // Then - expect(inputEvent).toHaveReceivedEventTimes(7); - }); - it('Should fire wcsChange event when user commit change with blur (tab)', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const input = await page.find('wcs-input'); - const changeEvent = await page.spyOnEvent('wcsChange'); - - // When - await input.click(); - await input.press('B'); - await input.press('l'); - await input.press('u'); - await input.press('r'); - await input.press('Tab'); - - await page.waitForChanges(); - - // Then - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); - }); - it('Should fire wcsChange event when user commit change with blur (click)', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const input = await page.find('wcs-input'); - const button = await page.find('button'); - const changeEvent = await page.spyOnEvent('wcsChange'); - - // When - await input.click(); - await input.press('B'); - await input.press('l'); - await input.press('u'); - await input.press('r'); - await button.focus(); - - // Then - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); - }); - it('Should fire wcsChange event when user commit change with enter', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const input = await page.find('wcs-input'); - const changeEvent = await page.spyOnEvent('wcsChange'); - - // When - await input.click(); - await input.press('E'); - await input.press('n'); - await input.press('t'); - await input.press('e'); - await input.press('r'); - await input.press('Enter'); - - // Then - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(changeEvent).toHaveReceivedEventDetail({ value: 'Enter' }); - }); - it('Should not fire wcsChange event when value is programmatically set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const input = await page.find('wcs-input'); - const changeEvent = await page.spyOnEvent('wcsChange'); - - // When - input.setProperty('value', 'Programmatically set value'); - - // Then - expect(changeEvent).toHaveReceivedEventTimes(0); - }); - it('Should not fire wcsInput event when value is programmatically set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const input = await page.find('wcs-input'); - const inputEvent = await page.spyOnEvent('wcsInput'); - - // When - input.setProperty('value', 'Programmatically set value'); - - // Then - expect(inputEvent).toHaveReceivedEventTimes(0); - }); - }); - - it('Should have a default value when value attribute is set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const input = await page.find('wcs-input'); - - // Then - expect(await input.getProperty('value')).toBe('Default value'); - expect(await (await page.find('wcs-input >>> input')).getProperty('value')).toBe('Default value'); - }); - - it('Should have a default value when value property is set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const input = await page.find('wcs-input'); - - // When - input.setProperty('value', 'Default value'); - await page.waitForChanges(); - - // Then expect(await input.getProperty('value')).toBe('Default value'); - expect(await (await page.find('wcs-input >>> input')).getProperty('value')).toBe('Default value'); - }); - - describe('Form Integration', () => { - it('should reflect validity from native input to form-associated custom element for required field', async () => { - // Given - const page = await newE2EPage(); - 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 = await page.find('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); - }); - - it('should include input value in form submission', async () => { - // Given - const page = await newE2EPage(); - 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['submittedData'] = { - hasInput: formData.has('test-input'), - inputValue: formData.get('test-input') - }; - }); - }); - - // When - submit form with empty input - const submitBtn = await page.find('button'); - await submitBtn.click(); - await page.waitForChanges(); - - // Then - input value should be included in form submission (empty string) - const emptyResult = await page.evaluate(() => window['submittedData']); - expect(emptyResult.hasInput).toBe(true); - expect(emptyResult.inputValue).toBe(''); - - // When - fill the input and submit again - const input = await page.find('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['submittedData']); - expect(filledResult.hasInput).toBe(true); - expect(filledResult.inputValue).toBe('Test value'); - }); - - it('should validate input against pattern attribute', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` -
- - -
- `); - - // Set up validity tracking - await page.evaluate(() => { - window['checkValidity'] = () => { - const form = document.getElementById('test-form') as HTMLFormElement; - return form.checkValidity(); - }; - }); - - // When - fill the input with invalid email - const input = await page.find('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['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['checkValidity']()); - expect(validCheck).toBe(true); - }); - - it('should validate min/max length attributes', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` -
- -
- `); - - // Set up validity tracking - await page.evaluate(() => { - window['checkValidity'] = () => { - const form = document.getElementById('test-form') as HTMLFormElement; - return form.checkValidity(); - }; - }); - - // When - fill the input with a value too short - const input = await page.find('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['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['checkValidity']()); - expect(validCheck).toBe(true); - }); - }); -}); diff --git a/src/components/select/select.e2e.playwright.ts b/src/components/select/select.e2e.playwright.ts index b6be2ae2..a7ed13af 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.e2e.ts b/src/components/select/select.e2e.ts deleted file mode 100644 index f70fa1bd..00000000 --- a/src/components/select/select.e2e.ts +++ /dev/null @@ -1,1344 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; -import { setWcsContent } from "../../utils/tests"; - -describe('Select component', () => { - it('Expands when clicked', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - - // When - await select.click(); - - // Then - expect(select).toHaveClass('expanded'); - }); - - it('Expands using the open method', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - - // When - await select.callMethod('open'); - await page.waitForChanges(); - - // Then - expect(select).toHaveClass('expanded'); - }); - - it('Closes using the open method', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - - // When - await select.click(); - await select.callMethod('close'); - await page.waitForChanges(); - - // Then - expect(select).not.toHaveClass('expanded'); - }); - - it('Closes when user click outside', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - -
- `); - const select = await page.find('wcs-select'); - - // When - await select.click(); - // XXX: Page.click() doesn't work - await page.$eval('div.outside', (elem: HTMLDivElement) => elem.click()); - await page.waitForChanges(); - - // Then - expect(select).not.toHaveClass('expanded'); - }); - - it('Closes when user click on another select', async () => { - // Given - const page = await newE2EPage(); - await page.setViewport({width: 1024, height: 1600}); - await setWcsContent(page, ` -
- - One - Two - Three - - - One - Two - Three - -
- `); - const select1 = await page.find('#select-1'); - const select2 = await page.find('#select-2'); - - // When - await select1.click(); - await page.waitForChanges(); - - // Then - expect(select1).toHaveClass('expanded'); - - await select2.click(); // select another select component in page - expect(select1).not.toHaveClass('expanded'); - expect(select2).toHaveClass('expanded'); - }); - - it('Let us select a value and fire event correctly', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - const firstSelectOption = await page.find('wcs-select > wcs-select-option'); - const label = await page.find('wcs-select >>> label'); - const changeSpy = await select.spyOnEvent('wcsChange'); - - // When - await select.click(); - await firstSelectOption.click(); - await page.waitForChanges(); - - // Then - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({ value: '1' }); - expect(label.innerText).toBe('One'); - }); - - describe('select event', () => { - it('should not emit event if we set the value in js', async () => { - // Given - const page = await newE2EPage({ - html: ` - - One - Two - - ` - }); - const select = await page.find('wcs-select'); - const changeSpy = await select.spyOnEvent('wcsChange'); - - // When - await select.setProperty('value', '2'); - await page.waitForChanges(); - - // Then - expect(changeSpy).toHaveReceivedEventTimes(0); - }); - }); - - describe('setSelectedValue', () => { - it('Let user change selected value programmatically', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - - `); - const select = await page.find('wcs-select'); - - // When - await select.setProperty('value', '2'); - await page.waitForChanges(); - - // Then - expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two"); - }); - - it('Let user change selected values programmatically', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - Three - - `); - const select = await page.find('wcs-select'); - - // When - const newValue = ['2', '3']; - await select.setProperty('value', newValue); - await page.waitForChanges(); - - // Then - expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two, Three"); - }); - }); - - - it('Is focusable', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - - // When - await select.focus(); - const focusedEl = await page.find('wcs-select:focus'); - - // Then - expect(focusedEl).toBeDefined(); - }); - - it('[Autocomplete] Input field is focusable', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - - // When - await select.focus(); - const focusedEl = await page.find('wcs-select > input.autocomplete-field:focus'); - - // Then - expect(focusedEl).toBeDefined(); - }) - - it('[Autocomplete] filter is cleared when the select value is set to a falsy value', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - Three - Four - - `); - const select = await page.find('wcs-select'); - - // When - await select.click(); - - await page.keyboard.type('One'); - const firstSelectOption = await page.find('wcs-select > wcs-select-option'); - - await firstSelectOption.click(); - await page.waitForChanges(); - - select.setProperty('value', ''); - await page.waitForChanges(); - - // Then - await select.click(); - const availableOptions = await page.findAll('wcs-select > *:not([hidden])'); - // We check that all options are available to ensure the filter is no longer active - expect(availableOptions.length).toBe(4); - }); - - it('[Autocomplete] should not opened when initial value is set', async () => { - // Given - When - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - Three - Four - - `); - const select = await page.find('wcs-select'); - - // Then - expect(select).not.toHaveClass('expanded'); - }); - - it('[Autocomplete] should not opened when set value programmatically', async () => { - // Given - When - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - Three - Four - - `); - const select = await page.find('wcs-select'); - select.setProperty('value', '1'); - await page.waitForChanges(); - - // Then - expect(select).not.toHaveClass('expanded'); - }) - - it('[Autocomplete] should opened when set value with user interaction', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - Three - Four - - `); - const select = await page.find('wcs-select'); - const autocompleteInput = await page.find('wcs-select >>> input.autocomplete-field'); - - // When - await autocompleteInput.focus(); - await page.keyboard.type('O'); - await page.waitForChanges(); - - // Then - expect(select).toHaveClass('expanded'); - }) - - it(`Propagate wcsSelectChangeEvent when a new value is selected`, async () => { - // Given - const page = await newE2EPage({ - html: ` - - One - - ` - }); - const select = await page.find('wcs-select'); - const opt = await page.find('wcs-select > wcs-select-option'); - const changeSpy = await select.spyOnEvent('wcsChange'); - // When - await select.click(); - await opt.click(); - await page.waitForChanges(); - - // Then - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({ value: '1' }); - }); - - xit(`Can have pre-selected option`, async () => { - // TODO: - }); - - describe('Disabled', () => { - it('Must not expand when disabled', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - - // When - await select.click(); - - // Then - expect(select).not.toHaveClass('expanded'); - }); - - it('Is not focusable when disabled', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - - // When - await select.focus(); - const focusedEl = await page.find('wcs-select:focus'); - - // Then - expect(focusedEl).toBeNull(); - }); - }); - - describe('Options', () => { - it('Adds selected attribute to selected option', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - const option = await page.find('wcs-select > wcs-select-option'); - - // When - await select.click(); - await option.click(); - await page.waitForChanges(); - - // Then - expect(option).toHaveAttribute('selected'); - }); - - it(`Removes selected attribute from previously selected options`, async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - - `); - const select = await page.find('wcs-select'); - const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); - - // When - await select.click(); - await opt1.click(); - await select.click(); // As it is not multiple we need to open it once again - await opt2.click(); - await page.waitForChanges(); - - // Then - expect(opt1).not.toHaveAttribute('selected'); - expect(opt2).toHaveAttribute('selected'); - }); - - it(`Must not let a user select a disabled option`, async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - const option = await page.find('wcs-select > wcs-select-option'); - - // When - await select.click(); - await option.click(); - await page.waitForChanges(); - - // Then - expect(select).not.toHaveAttribute('value'); - }); - }); - - describe('Multiple', () => { - it(`Musn't close when we select a value`, async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const select = await page.find('wcs-select'); - const firstSelectOption = await page.find('wcs-select > wcs-select-option'); - - // When - await select.click(); - await firstSelectOption.click(); - await page.waitForChanges(); - - // Then - expect(select).toHaveClass('expanded'); - }); - - it(`Allows to select multiple values`, async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - - `); - const select = await page.find('wcs-select'); - const changeSpy = await select.spyOnEvent('wcsChange'); - const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); - - // When - await select.click(); - await opt1.click(); - await opt2.click(); - await page.waitForChanges(); - - // Then - expect(changeSpy).toHaveReceivedEventTimes(2); - expect(changeSpy).toHaveReceivedEventDetail({ value: ['1', '2'] }); - }); - - - it('Allows to unselect a value', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - Three - - `); - const select = await page.find('wcs-select'); - const changeSpy = await select.spyOnEvent('wcsChange'); - const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); - - // When - await select.click(); - await opt1.click(); - await opt2.click(); - await opt1.click(); - await page.waitForChanges(); - - // Then - expect(changeSpy).toHaveReceivedEventTimes(3); - expect(changeSpy).toHaveReceivedEventDetail({ value: ['2'] }); - }); - it(`Displays all values separated by a comma`, async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - Three - - `); - const select = await page.find('wcs-select'); - const [opt1, opt2, opt3] = (await page.findAll('wcs-select > wcs-select-option')); - const label = await page.find('wcs-select >>> label'); - - // When - await select.click(); - await opt1.click(); - await opt2.click(); - await opt3.click(); - await page.waitForChanges(); - - // Then - expect(label.innerText).toEqual('One, Two, Three'); - }); - - it(`Tells the option that they should display as multiple`, async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - - `); - const option = await page.find('wcs-select > wcs-select-option'); - - // When - await page.waitForChanges(); - - // Then - expect(option).toHaveAttribute('multiple'); - }); - - it(`Propagate event when values are select`, async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - One - Two - - `); - const select = await page.find('wcs-select'); - const [opt1, opt2] = (await page.findAll('wcs-select > wcs-select-option')); - const changeSpy = await select.spyOnEvent('wcsChange'); - - // When - await select.click(); - await opt1.click(); - await opt2.click(); - - // Then - expect(changeSpy).toHaveReceivedEventTimes(2); - expect(changeSpy).toHaveReceivedEventDetail({ value: ['1', '2'] }); - }); - }); - - describe('Keyboard navigation when select is closed and not multiple', () => { - let page; - let wcsSelect; - - beforeEach(async () => { - // Given - page = await newE2EPage(); - await setWcsContent(page, ` - - Option 1 - Option 2 - Option 3 - - `); - wcsSelect = await page.find('wcs-select'); - }); - - it('select value of first option enabled on Down Arrow key pressed', async () => { - // Given - const firstDisplayedValueOfEnableOption = "Option 2"; - const firstValueOfEnableOption = "option2"; - const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); - const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); - - // When - await wcsSelect.focus(); - await page.keyboard.press('ArrowDown'); - await page.waitForChanges(); - - // Then - expect(displayValue.innerText).toEqual(firstDisplayedValueOfEnableOption); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({ value: firstValueOfEnableOption }); - }); - it('select value of last option enabled on PageDown key pressed', async () => { - // Given - const lastValueOfEnableOption = "Option 3"; - const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); - const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); - - // When - await wcsSelect.focus(); - await page.keyboard.press('PageDown'); - await page.waitForChanges(); - - // Then - expect(displayValue.innerText).toEqual(lastValueOfEnableOption); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({ value: 'option3' }); - }); - it('select value of first option enabled on PageUp key pressed', async () => { - // Given - const fistDisplayedValueOfEnableOption = "Option 2"; - const firstValueOfEnableOption = "option2"; - const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); - const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); - - // When - await wcsSelect.focus(); - await page.keyboard.press('PageUp'); - await page.waitForChanges(); - - // Then - expect(displayValue.innerText).toEqual(fistDisplayedValueOfEnableOption); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({ value: firstValueOfEnableOption }); - }); - it('open the overlay on Enter key press', async () => { - // When - await wcsSelect.focus(); - await page.keyboard.press('Enter'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).toHaveClass("expanded"); - }); - it('open the overlay on Alt + Down Arrow key pressed', async () => { - // When - await wcsSelect.focus(); - await page.keyboard.down('Alt'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.up('Alt'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).toHaveClass("expanded"); - - }); - - it('focuses last selected option when opening with keyboard after programmatic value change', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - Option 1 - Option 2 - Option 3 - - `); - const select = await page.find('wcs-select'); - - // When - await select.setProperty('value', 'option2'); - await page.waitForChanges(); - await select.focus(); - await page.keyboard.press('Enter'); // Open select with keyboard - await page.waitForChanges(); - - // Then - const focusedOption = await page.find('wcs-select-option:focus'); - expect(focusedOption.getAttribute('value')).toBe('option2'); - }); - }); - describe('Keyboard navigation when select is opened and not multiple', () => { - let page; - let wcsSelect; - let selectOptions; - - beforeEach(async () => { - // Given - page = await newE2EPage(); - await setWcsContent(page, ` - - Option 1 - Option 2 - Option 3 - - `); - wcsSelect = await page.find('wcs-select'); - selectOptions = await page.findAll('wcs-select > wcs-select-option'); - // Open by default the overlay for each test - await wcsSelect.focus(); - await page.keyboard.press('Enter'); - await page.waitForChanges(); - }); - - it('close the overlay on Escape key pressed and focus select control', async () => { - // Given - // The overlay is open (makes in beforeEach) - - // When - await page.keyboard.press('Escape'); - const focusedEl = await page.find('wcs-select:focus'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - expect(focusedEl).toBeDefined(); - }); - - it('close the overlay on Alt + ArrowUp keys pressed and focus select control', async () => { - // Given - // The overlay is open (makes in beforeEach) - - // When - await page.keyboard.down('Alt'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.up('Alt'); - const focusedEl = await page.find('wcs-select:focus'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - expect(focusedEl).toBeDefined(); - }); - it('close the overlay on Tab key pressed', async () => { - // Given - // The overlay is open (makes in beforeEach) - - // When - await page.keyboard.press('Tab'); - const focusedEl = await page.find('wcs-select:focus'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - expect(focusedEl).toBeDefined(); - }); - it('close the overlay on Tab + Shift keys pressed', async () => { - // Given - // The overlay is open (makes in beforeEach) - - // When - await page.keyboard.down('Shift'); - await page.keyboard.press('Tab'); - await page.keyboard.up('Shift'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - const focusedSelect = await page.find('wcs-select:focus'); - expect(focusedSelect).toBeDefined(); - }); - it('choose the current option on Enter key pressed', async () => { - // Given - // The overlay is open (makes in beforeEach) - const firstDisplayedValueOfEnableOption = "Option 2"; - const firstValueOfEnabledOption = "option2"; - const displayValue: HTMLLabelElement = await page.find('wcs-select >>> label'); - const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); - - // When - await page.keyboard.press('Enter'); - await page.waitForChanges(); - - // Then - expect(displayValue.innerText).toEqual(firstDisplayedValueOfEnableOption); - expect(changeSpy).toHaveReceivedEventTimes(1); - expect(changeSpy).toHaveReceivedEventDetail({ value: firstValueOfEnabledOption }); - }); - it('move focus to next option on Down Arrow key down', async () => { - // Given - // The overlay is open (makes in beforeEach) - const nextValueOfEnableOption = selectOptions[2]; - const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); - - // When - await page.keyboard.press('ArrowDown'); - const focusedOption = await page.find('wcs-select-option:focus'); - - // Then - expect(focusedOption.value).toEqual(nextValueOfEnableOption.value); - expect(changeSpy).toHaveReceivedEventTimes(0) - }); - }); - describe('Keyboard navigation when select is closed and multiple', () => { - let page; - let wcsSelect; - let selectOptions; - - beforeEach(async () => { - // Given - page = await newE2EPage(); - await setWcsContent(page, ` - - Option 1 - Option 2 - Option 3 - - `); - wcsSelect = await page.find('wcs-select'); - selectOptions = await page.findAll('wcs-select > wcs-select-option'); - }); - - it('move focus into the first enabled option on Down Arrow key pressed', async () => { - // Given - const firstOptionEnabled = selectOptions[1]; - const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); - - // When - await wcsSelect.focus(); - await page.keyboard.press('ArrowDown'); - await page.waitForChanges(); - - // Then - const focusedOption = await page.find('wcs-select-option:focus'); - expect(focusedOption).toEqual(firstOptionEnabled); - expect(changeSpy).toHaveReceivedEventTimes(0) - }); - it('move focus into the first enabled option on Enter key pressed', async () => { - // Given - const firstOptionEnabled = selectOptions[1]; - const changeSpy = await wcsSelect.spyOnEvent('wcsChange'); - - // When - await wcsSelect.focus(); - await page.keyboard.press('Enter'); - await page.waitForChanges(); - - // Then - const focusedOption = await page.find('wcs-select-option:focus'); - expect(focusedOption).toEqual(firstOptionEnabled); - expect(changeSpy).toHaveReceivedEventTimes(0) - }); - - it('focuses last selected option when opening with keyboard after programmatic value change', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - Option 1 - Option 2 - Option 3 - - `); - const select = await page.find('wcs-select'); - - // When - await select.setProperty('value', ['option1', 'option2']); - await page.waitForChanges(); - await select.focus(); - await page.keyboard.press('Enter'); // Open select with keyboard - await page.waitForChanges(); - - // Then - const focusedOption = await page.find('wcs-select-option:focus'); - expect(focusedOption.getAttribute('value')).toBe('option2'); // Should focus the last option in the array - }); - }); - describe('Keyboard navigation when select opened and multiple', () => { - let page; - let wcsSelect; - - beforeEach(async () => { - // Given - page = await newE2EPage(); - await setWcsContent(page, ` - - Option 1 - Option 2 - Option 3 - - `); - wcsSelect = await page.find('wcs-select'); - // Open by default the overlay for each test - await wcsSelect.focus(); - await page.keyboard.press('Enter'); - await page.waitForChanges(); - }); - - it('close the overlay on Tab key pressed and not focus an checkbox', async () => { - // Given - // The overlay is open (makes in beforeEach) - - // When - await page.keyboard.press('Tab'); - const focusedEl = await page.find('wcs-select:focus'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - expect(focusedEl).toBeDefined(); - }); - }); - //region Autocomplete tests - describe('[Autocomplete] Keyboard navigation when select closed', () => { - let page; - let wcsSelect; - let selectOptions; - let wcsAutocompleteInput; - - beforeEach(async () => { - // Given - page = await newE2EPage(); - await setWcsContent(page, ` - - Option 1 - Option 2 - Option 3 - - `); - wcsSelect = await page.find('wcs-select'); - selectOptions = await page.findAll('wcs-select > wcs-select-option'); - wcsAutocompleteInput = await page.find('wcs-select >>> input.autocomplete-field'); - }); - - it('open listbox and move focus into the first enabled option on Arrow Down pressed', async () => { - const firstOptionEnabled = selectOptions[1]; - - // When - await wcsSelect.focus(); - await page.keyboard.press('ArrowDown'); - await page.waitForChanges(); - - // Then - const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); - expect(visuallyFocusedOption).toEqual(firstOptionEnabled); - expect(wcsSelect).toHaveClass("expanded"); - }); - - it('open listbox without moveing on Alt + Arrow Down pressed', async () => { - // When - await wcsSelect.focus(); - await page.keyboard.down('Alt'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.up('Alt'); - await page.waitForChanges(); - - // Then - const anyVisuallyFocusedOption= await page.find('wcs-select-option[highlighted]'); - expect(wcsSelect).toHaveClass("expanded"); - expect(anyVisuallyFocusedOption).toBeNull(); - }); - it('clear textbox on Escape pressed', async () => { - // Given - await wcsAutocompleteInput.type('test'); - await page.waitForChanges(); - - // When - await wcsAutocompleteInput.click(); - await page.keyboard.press('Escape'); - await page.waitForChanges(); - - // Then - const value = await wcsAutocompleteInput.getProperty('value'); - expect(value).toEqual(''); - }); - }); - describe('[Autocomplete] Keyboard navigation when select expanded', () => { - let page; - let wcsSelect; - let selectOptions; - let autocompleteInput; - - beforeEach(async () => { - // Given - page = await newE2EPage(); - await setWcsContent(page, ` - - Apple - Banana - Peach - - `); - wcsSelect = await page.find('wcs-select'); - selectOptions = await page.findAll('wcs-select > wcs-select-option'); - autocompleteInput = await page.find('wcs-select >>> input.autocomplete-field'); - }); - - it('close listbox on Escape', async () => { - // When - await autocompleteInput.click(); - await page.keyboard.press('Escape'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - }); - it('stay opened on Enter when no option are highlighted', async () => { - // When - await autocompleteInput.click(); - await page.waitForChanges(); - await page.keyboard.press('Enter'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).toHaveClass("expanded"); - }); - it('Close overlay when an highlighted option is selected with Enter keypress', async () => { - // When - await autocompleteInput.click(); - await page.waitForChanges(); - await page.keyboard.press('ArrowDown'); - await page.waitForChanges(); - await page.keyboard.press('Enter'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - }); - it('focus last option on Arrow Up', async () => { - // Given - const lastOption = selectOptions[selectOptions.length - 1]; - - // When - await autocompleteInput.focus(); - await page.keyboard.press('ArrowUp'); - await page.waitForChanges(); - - // Then - const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); - expect(visuallyFocusedOption).toEqual(lastOption); - }); - it('focus first option on Arrow Down', async () => { - // Given - const firstOption = selectOptions[1]; // Because first option is disabled - - // When - await autocompleteInput.focus(); - await page.keyboard.press('ArrowDown'); - await page.waitForChanges(); - - // Then - const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); - expect(visuallyFocusedOption).toEqual(firstOption); - }); - it('replace text, close listbox, focus textbox on Enter', async () => { - // Given - const firstOption = selectOptions[1]; // Because first option is disabled - - // When - await autocompleteInput.focus(); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Enter'); - await page.waitForChanges(); - - // Then - expect(await autocompleteInput.getProperty('value')).toEqual(firstOption.textContent); - expect(wcsSelect).not.toHaveClass("expanded"); - const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); - expect(focusedInput).toBeDefined(); - }); - it('close listbox, focus textbox on Escape', async () => { - // When - await page.keyboard.press('Escape'); - await page.waitForChanges(); - - // Then - expect(wcsSelect).not.toHaveClass("expanded"); - const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); - expect(focusedInput).toBeDefined(); - }); - it('cycle to next option when Arrow Down', async () => { - // Given - const firstSelectableOption = selectOptions[1]; - - // When - await wcsSelect.focus(); - await page.keyboard.press('ArrowDown'); // Going to option[1] - await page.keyboard.press('ArrowDown'); // Going to option[2] - await page.keyboard.press('ArrowDown'); // Going back to option[1] - await page.waitForChanges(); - - // Then - const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); - expect(visuallyFocusedOption).toEqual(firstSelectableOption); - }); - it('cycle to previous option when Arrow Up', async () => { - // Given - const lastSelectableOption = selectOptions[2]; - - // When - await wcsSelect.focus(); - await page.keyboard.press('ArrowUp'); // Going to option[2] - await page.keyboard.press('ArrowUp'); // Going to option[1] - await page.keyboard.press('ArrowUp'); // Going back to option[2] - await page.waitForChanges(); - - // Then - const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); - expect(visuallyFocusedOption).toEqual(lastSelectableOption); - }); - it('focus textbox, move cursor when Left or Right Arrow', async () => { - // Given - const typedText = 'test'; - - // When - await wcsSelect.focus(); - await autocompleteInput.type(typedText); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowRight'); - await page.waitForChanges(); - - // Then - const cursorPositionAfter = await autocompleteInput.getProperty('selectionStart'); - expect(cursorPositionAfter).toEqual(typedText.length); - const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); - expect(focusedInput).toBeDefined(); - }); - it('focus textbox, move cursor to the start of the text when Home pressed', async () => { - // Given - const typedText = 'test'; - - // When - await autocompleteInput.type(typedText); - await page.keyboard.press('Home'); - await page.waitForChanges(); - - // Then - const cursorPositionAfter = await autocompleteInput.getProperty('selectionStart'); - expect(cursorPositionAfter).toEqual(0); - const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); - expect(focusedInput).toBeDefined(); - }); - it('focus textbox, move cursor to the end of the text when End pressed', async () => { - // Given - const typedText = 'test'; - - // When - await autocompleteInput.press('t'); - await autocompleteInput.press('e'); - await autocompleteInput.press('s'); - await autocompleteInput.press('t'); - await page.keyboard.press('End'); - await page.waitForChanges(); - - // Then - const cursorPositionAfter = await autocompleteInput.getProperty('selectionStart'); - expect(cursorPositionAfter).toEqual(typedText.length); - const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); - expect(focusedInput).toBeDefined(); - }); - it('focus textbox, filter listbox, remove visual focus from listbox when any printable character', async () => { - // Given - const allOptions = await page.findAll('wcs-select-option'); - - // When - await wcsSelect.focus(); - await page.keyboard.press('a'); - await page.waitForChanges(); - - // Then - const focusedInput = await page.find('wcs-select > input.autocomplete-field:focus'); - expect(focusedInput).toBeDefined(); // Focus textbox - const optionsWithFilter = await page.findAll('wcs-select-option:not([aria-hidden])'); - expect(optionsWithFilter.length).toBeLessThan(allOptions.length); // Filter listbox - const visuallyFocusedOption = await page.find('wcs-select-option[highlighted]'); - expect(visuallyFocusedOption).toBeNull(); // Remove visual focus from listbox - }); - }); - //endregion - - describe('Form Integration', () => { - it('should reflect validity from select for required field', async () => { - // Given - const page = await newE2EPage(); - 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 = await page.find('wcs-select'); - const firstSelectOption = await page.find('wcs-select > wcs-select-option'); - 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); - }); - - it('should include select value in form submission', async () => { - // Given - const page = await newE2EPage(); - 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['submittedData'] = { - hasSelect: formData.has('test-select'), - selectValue: formData.get('test-select') - }; - }); - }); - - // When - submit form with empty select - const submitBtn = await page.find('button'); - await submitBtn.click(); - await page.waitForChanges(); - - // Then - select value should be included in form submission (empty string) - const emptyResult = await page.evaluate(() => window['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 = await page.find('wcs-select'); - const firstSelectOption = await page.find('wcs-select > wcs-select-option'); - 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['submittedData']); - expect(filledResult.hasSelect).toBe(true); - expect(filledResult.selectValue).toBe('1'); - }); - }); - - it('Should handle asynchronous options loading with an initial value and update the label', async () => { - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const select = await page.find('wcs-select'); - - expect(select.shadowRoot.querySelector('.wcs-select-value')).toBeNull(); // Verify initial value is displayed - - // Add options - await page.$eval('wcs-select', (el: HTMLElement) => { - el.innerHTML = ` - One - Two - Three - Four - `; - }); - await page.waitForChanges(); - - expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two"); // Verify initial value is displayed - }); - - it('[Multiple] Should handle asynchronous options loading with an initial value and update the label', async () => { - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const select = await page.find('wcs-select'); - select.setProperty('value', ['2', '3']); - await page.waitForChanges(); - - expect(select.shadowRoot.querySelector('.wcs-select-value')).toBeNull(); // Verify initial value is displayed - - // Add options - await page.$eval('wcs-select', (el: HTMLElement) => { - el.innerHTML = ` - One - Two - Three - Four - `; - }); - await page.waitForChanges(); - - expect(select.shadowRoot.querySelector('.wcs-select-value')).toEqualText("Two, Three"); // Verify initial value is displayed - }); - -}); - diff --git a/src/components/switch/switch.e2e.playwright.ts b/src/components/switch/switch.e2e.playwright.ts index 92dee1c1..f4753cd3 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.e2e.ts b/src/components/switch/switch.e2e.ts deleted file mode 100644 index fba86a76..00000000 --- a/src/components/switch/switch.e2e.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { newE2EPage } from "@stencil/core/testing"; -import { setWcsContent } from "../../utils/tests"; - -describe('Switch component', () => { - describe('Events', () => { - it('should emit a wcsChange event when clicked on wcs-switch', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - Switch - - `); - - // When - const switchElement = await page.find('wcs-switch'); - const eventSpy = await switchElement.spyOnEvent('wcsChange'); - - await switchElement.click(); - await page.waitForChanges(); - - // Then - expect(eventSpy) - .toHaveReceivedEventDetail({ - checked: true - }); - }); - - it('should emit a wcsChange event when space pressed on wcs-switch', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - Switch - - `); - - // When - const switchElement = await page.find('wcs-switch'); - const eventSpy = await switchElement.spyOnEvent('wcsChange'); - - await switchElement.press('Space'); - await page.waitForChanges(); - - // Then - expect(eventSpy) - .toHaveReceivedEventDetail({ - checked: true - }); - }); - }); - describe('Form Integration', () => { - it('should reflect validity from native input to form-associated custom element', async () => { - // Given - const page = await newE2EPage(); - 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 = await page.find('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); - }); - - it('should include switch value in form submission when checked', async () => { - // Given - const page = await newE2EPage(); - 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['submittedData'] = { - hasSwitch: formData.has('test-switch'), - switchValue: formData.get('test-switch') - }; - }); - }); - - // When - submit form with unchecked switch - const submitBtn = await page.find('button'); - await submitBtn.click(); - await page.waitForChanges(); - - // Then - switch value should not be included in form submission - const uncheckedResult = await page.evaluate(() => window['submittedData']); - expect(uncheckedResult.hasSwitch).toBe(false); - - // When - check the switch and submit again - const switchElement = await page.find('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['submittedData']); - expect(checkedResult.hasSwitch).toBe(true); - expect(checkedResult.switchValue).toBe('on'); - }); - }); -}); diff --git a/src/components/textarea/textarea.e2e.playwright.ts b/src/components/textarea/textarea.e2e.playwright.ts index e37da3f2..53c4c0aa 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.e2e.ts b/src/components/textarea/textarea.e2e.ts deleted file mode 100644 index e2e02f9c..00000000 --- a/src/components/textarea/textarea.e2e.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { newE2EPage } from '@stencil/core/testing'; -import { setWcsContent } from "../../utils/tests"; - -describe('Textarea component', () => { - describe('Events', () => { - it('Should fire wcsInput event once when user typing one char', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const textarea = await page.find('wcs-textarea'); - const inputEvent = await page.spyOnEvent('wcsInput'); - - // When - await textarea.click(); - await textarea.press('B'); - - // Then - expect(inputEvent).toHaveReceivedEventTimes(1); - }); - - - it('Should fire wcsInput event multiple times when user typing multiple chars', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const textarea = await page.find('wcs-textarea'); - const inputEvent = await page.spyOnEvent('wcsInput'); - - // When - await textarea.click(); - await textarea.press('B'); - await textarea.press('o'); - await textarea.press('n'); - await textarea.press('j'); - await textarea.press('o'); - await textarea.press('u'); - await textarea.press('r'); - - // Then - expect(inputEvent).toHaveReceivedEventTimes(7); - }); - it('Should fire wcsChange event when user commit change with blur (tab)', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const textarea = await page.find('wcs-textarea'); - const changeEvent = await page.spyOnEvent('wcsChange'); - - // When - await textarea.click(); - await textarea.press('B'); - await textarea.press('l'); - await textarea.press('u'); - await textarea.press('r'); - await textarea.press('Tab'); - - await page.waitForChanges(); - - // Then - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); - }); - it('Should fire wcsChange event when user commit change with blur (click)', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - - `); - const textarea = await page.find('wcs-textarea'); - const button = await page.find('button'); - const changeEvent = await page.spyOnEvent('wcsChange'); - - // When - await textarea.click(); - await textarea.press('B'); - await textarea.press('l'); - await textarea.press('u'); - await textarea.press('r'); - await button.focus(); - - // Then - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(changeEvent).toHaveReceivedEventDetail({ value: 'Blur' }); - }); - it('Should not fire wcsChange event when value is programmatically set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const textarea = await page.find('wcs-textarea'); - const changeEvent = await page.spyOnEvent('wcsChange'); - - // When - textarea.setProperty('value', 'Programmatically set value'); - - // Then - expect(changeEvent).toHaveReceivedEventTimes(0); - }); - it('Should not fire wcsInput event when value is programmatically set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const textarea = await page.find('wcs-textarea'); - const inputEvent = await page.spyOnEvent('wcsInput'); - - // When - textarea.setProperty('value', 'Programmatically set value'); - - // Then - expect(inputEvent).toHaveReceivedEventTimes(0); - }); - }); - - it('Should have a default value when value attribute is set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const textarea = await page.find('wcs-textarea'); - - // Then - expect(await textarea.getProperty('value')).toBe('Default value'); - expect(await (await page.find('wcs-textarea >>> textarea')).getProperty('value')).toBe('Default value'); - }); - - it('Should have a default value when value property is set', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` - - `); - const textarea = await page.find('wcs-textarea'); - - // When - textarea.setProperty('value', 'Default value'); - await page.waitForChanges(); - - // Then - expect(await textarea.getProperty('value')).toBe('Default value'); - expect(await (await page.find('wcs-textarea >>> textarea')).getProperty('value')).toBe('Default value'); - }); - - describe('Form Integration', () => { - it('should reflect validity from native textarea to form-associated custom element for required field', async () => { - // Given - const page = await newE2EPage(); - 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 = await page.find('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); - }); - - it('should include textarea value in form submission', async () => { - // Given - const page = await newE2EPage(); - 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['submittedData'] = { - hasTextarea: formData.has('test-textarea'), - textareaValue: formData.get('test-textarea') - }; - }); - }); - - // When - submit form with empty textarea - const submitBtn = await page.find('button'); - await submitBtn.click(); - await page.waitForChanges(); - - // Then - textarea value should be included in form submission (empty string) - const emptyResult = await page.evaluate(() => window['submittedData']); - expect(emptyResult.hasTextarea).toBe(true); - expect(emptyResult.textareaValue).toBe(''); - - // When - fill the textarea and submit again - const textarea = await page.find('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['submittedData']); - expect(filledResult.hasTextarea).toBe(true); - expect(filledResult.textareaValue).toBe('Test value'); - }); - - it('should validate min/max length attributes', async () => { - // Given - const page = await newE2EPage(); - await setWcsContent(page, ` -
- -
- `); - - // Set up validity tracking - await page.evaluate(() => { - window['checkValidity'] = () => { - const form = document.getElementById('test-form') as HTMLFormElement; - return form.checkValidity(); - }; - }); - - // When - fill the textarea with a value too short - const textarea = await page.find('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['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['checkValidity']()); - expect(validCheck).toBe(true); - }); - }); -}); -- GitLab