diff --git a/packages/angular/src/lib/stencil-generated/components.ts b/packages/angular/src/lib/stencil-generated/components.ts index 922e6324c..07478416f 100644 --- a/packages/angular/src/lib/stencil-generated/components.ts +++ b/packages/angular/src/lib/stencil-generated/components.ts @@ -1920,8 +1920,8 @@ export declare interface GcdsSearch extends Components.GcdsSearch { @ProxyCmp({ - inputs: ['defaultValue', 'disabled', 'errorMessage', 'hint', 'label', 'name', 'required', 'selectId', 'validateOn', 'validator', 'value'], - methods: ['validate'], + inputs: ['autocomplete', 'autofocus', 'defaultValue', 'disabled', 'errorMessage', 'form', 'hint', 'label', 'name', 'required', 'selectId', 'validateOn', 'validator', 'validity', 'value'], + methods: ['validate', 'checkValidity', 'getValidationMessage'], outputs: ['gcdsChange', 'gcdsInput', 'gcdsFocus', 'gcdsBlur', 'gcdsError', 'gcdsValid'] }) @Component({ @@ -1929,7 +1929,7 @@ export declare interface GcdsSearch extends Components.GcdsSearch { changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['defaultValue', 'disabled', 'errorMessage', 'hint', 'label', 'name', 'required', 'selectId', 'validateOn', 'validator', 'value'], + inputs: ['autocomplete', 'autofocus', 'defaultValue', 'disabled', 'errorMessage', 'form', 'hint', 'label', 'name', 'required', 'selectId', 'validateOn', 'validator', 'validity', 'value'], outputs: ['gcdsChange', 'gcdsInput', 'gcdsFocus', 'gcdsBlur', 'gcdsError', 'gcdsValid'], standalone: false, }) @@ -1960,6 +1960,18 @@ export class GcdsSelect { */ set defaultValue(_: Components.GcdsSelect['defaultValue']) {}; /** + * If true, the select will be focused on component render + */ + set autofocus(_: Components.GcdsSelect['autofocus']) {}; + /** + * The ID of the form that the select field belongs to. + */ + set form(_: Components.GcdsSelect['form']) {}; + /** + * String to have autocomplete enabled. + */ + set autocomplete(_: Components.GcdsSelect['autocomplete']) {}; + /** * Value for a select element. */ set value(_: Components.GcdsSelect['value']) {}; @@ -1972,6 +1984,10 @@ export class GcdsSelect { */ set hint(_: Components.GcdsSelect['hint']) {}; /** + * Read-only property of the select, returns a ValidityState object that represents the validity states this element is in. @readonly + */ + set validity(_: Components.GcdsSelect['validity']) {}; + /** * Array of validators */ set validator(_: Components.GcdsSelect['validator']) {}; diff --git a/packages/vue/lib/components.ts b/packages/vue/lib/components.ts index dfe2f1549..288f28720 100644 --- a/packages/vue/lib/components.ts +++ b/packages/vue/lib/components.ts @@ -547,9 +547,13 @@ export const GcdsSelect: StencilVueComponent", + "docs": "" + }, + "complexType": { + "signature": "() => Promise", + "parameters": [], + "references": { + "Promise": { + "location": "global", + "id": "global::Promise" + } + }, + "return": "Promise" + }, + "signature": "checkValidity() => Promise", + "parameters": [], + "docs": "Check the validity of gcds-select", + "docsTags": [] + }, + { + "name": "getValidationMessage", + "returns": { + "type": "Promise", + "docs": "" + }, + "complexType": { + "signature": "() => Promise", + "parameters": [], + "references": { + "Promise": { + "location": "global", + "id": "global::Promise" + } + }, + "return": "Promise" + }, + "signature": "getValidationMessage() => Promise", + "parameters": [], + "docs": "Get validationMessage of gcds-select", + "docsTags": [] + }, { "name": "validate", "returns": { diff --git a/packages/web/src/components.d.ts b/packages/web/src/components.d.ts index ae8dc4649..40589c599 100644 --- a/packages/web/src/components.d.ts +++ b/packages/web/src/components.d.ts @@ -1102,6 +1102,18 @@ export namespace Components { * A select provides a large list of options for single selection. */ interface GcdsSelect { + /** + * String to have autocomplete enabled. + */ + "autocomplete"?: string; + /** + * If true, the select will be focused on component render + */ + "autofocus": boolean; + /** + * Check the validity of gcds-select + */ + "checkValidity": () => Promise; /** * The default value is an optional value that gets displayed before the user selects an option. */ @@ -1115,6 +1127,14 @@ export namespace Components { * Error message for an invalid select element. */ "errorMessage"?: string; + /** + * The ID of the form that the select field belongs to. + */ + "form"?: string; + /** + * Get validationMessage of gcds-select + */ + "getValidationMessage": () => Promise; /** * Hint displayed below the label. */ @@ -1151,6 +1171,11 @@ export namespace Components { "validator": Array< string | ValidatorEntry | Validator >; + /** + * Read-only property of the select, returns a ValidityState object that represents the validity states this element is in. + * @readonly + */ + "validity": ValidityState; /** * Value for a select element. */ @@ -3556,6 +3581,14 @@ declare namespace LocalJSX { * A select provides a large list of options for single selection. */ interface GcdsSelect { + /** + * String to have autocomplete enabled. + */ + "autocomplete"?: string; + /** + * If true, the select will be focused on component render + */ + "autofocus"?: boolean; /** * The default value is an optional value that gets displayed before the user selects an option. */ @@ -3569,6 +3602,10 @@ declare namespace LocalJSX { * Error message for an invalid select element. */ "errorMessage"?: string; + /** + * The ID of the form that the select field belongs to. + */ + "form"?: string; /** * Hint displayed below the label. */ @@ -3625,6 +3662,11 @@ declare namespace LocalJSX { "validator"?: Array< string | ValidatorEntry | Validator >; + /** + * Read-only property of the select, returns a ValidityState object that represents the validity states this element is in. + * @readonly + */ + "validity"?: ValidityState; /** * Value for a select element. */ diff --git a/packages/web/src/components/gcds-select/gcds-select.tsx b/packages/web/src/components/gcds-select/gcds-select.tsx index d9af76b7b..5728a1754 100644 --- a/packages/web/src/components/gcds-select/gcds-select.tsx +++ b/packages/web/src/components/gcds-select/gcds-select.tsx @@ -47,6 +47,8 @@ export class GcdsSelect { private shadowElement?: HTMLSelectElement; + private selectTitle: string = ''; + _validator: Validator = defaultValidator; /** @@ -90,6 +92,22 @@ export class GcdsSelect { */ @Prop({ reflect: true, mutable: false }) defaultValue?: string; + + /** + * If true, the select will be focused on component render + */ + @Prop({ reflect: true }) autofocus: boolean; + + /** + * The ID of the form that the select field belongs to. + */ + @Prop({ reflect: true }) form?: string; + + /** + * String to have autocomplete enabled. + */ + @Prop() autocomplete?: string; + /** * Value for a select element. */ @@ -97,7 +115,17 @@ export class GcdsSelect { @Watch('value') watchValue(val) { - this.internals.setFormValue(val ? val : null); + if (!this.shadowElement) return; + + if (val && this.checkIfValidValue(val)) { + this.internals.setFormValue(val); + this.shadowElement.value = val; + } else { + this.internals.setFormValue(null); + this.value = null; + } + + this.updateValidity(); } /** @@ -120,6 +148,14 @@ export class GcdsSelect { */ @Prop({ reflect: true, mutable: false }) hint?: string; + /** + * Read-only property of the select, returns a ValidityState object that represents the validity states this element is in. + */ + @Prop() + get validity() { + return this.internals.validity; + } + /** * Array of validators */ @@ -197,6 +233,8 @@ export class GcdsSelect { if (e.type === 'change') { const changeEvt = new e.constructor(e.type, e); this.el.dispatchEvent(changeEvt); + } else { + this.updateValidity(); } customEvent.emit(this.value); @@ -233,6 +271,25 @@ export class GcdsSelect { this.gcdsValid, this.lang, ); + + + this.selectTitle = this.errorMessage; + } + + /** + * Check the validity of gcds-select + */ + @Method() + public async checkValidity(): Promise { + return this.internals.checkValidity(); + } + + /** + * Get validationMessage of gcds-select + */ + @Method() + public async getValidationMessage(): Promise { + return this.internals.validationMessage; } /** @@ -268,15 +325,39 @@ export class GcdsSelect { option.setAttribute('selected', 'true'); this.internals.setFormValue(value); this.initialValue = this.value; - } - - if (option.hasAttribute('selected')) { + } else if (option.hasAttribute('selected')) { this.value = value; this.internals.setFormValue(value); this.initialValue = this.value ? this.value : null; } } + private checkIfValidValue(value: string) { + let isValid = false; + + this.options.forEach(option => { + if (option.nodeName === 'OPTION') { + const optionValue = option.getAttribute('value'); + + if (optionValue === value) { + isValid = true; + } + } else if (option.nodeName === 'OPTGROUP') { + const subOptions = Array.from(option.children); + + subOptions.map(sub => { + const subOptionValue = sub.getAttribute('value'); + + if (subOptionValue === value) { + isValid = true; + } + }); + } + }); + + return isValid; + } + /* * Form internal functions */ @@ -292,6 +373,29 @@ export class GcdsSelect { this.value = state; } + /** + * Update gcds-select's validity using internal select + */ + private updateValidity() { + if (!this.shadowElement) return; + + const validity = this.shadowElement.validity; + + let validationMessage = null; + if (validity?.valueMissing) { + validationMessage = this.lang === 'en' ? 'Choose an option to continue.' : 'Choisissez une option pour continuer.'; + } + + this.internals.setValidity( + validity, + validationMessage, + this.shadowElement, + ); + + // Set select title when HTML error occruring + this.selectTitle = validationMessage; + } + /* * Observe passed options and update if change */ @@ -353,11 +457,21 @@ export class GcdsSelect { }); } }); + this.value = this.checkIfValidValue(this.value) ? this.value : null; } } async componentDidLoad() { this.observeOptions(); + + this.updateValidity(); + + // Logic to enable autofocus + if (this.autofocus) { + requestAnimationFrame(() => { + this.shadowElement?.focus(); + }); + } } render() { @@ -375,6 +489,10 @@ export class GcdsSelect { hasError, name, options, + selectTitle, + autofocus, + form, + autocomplete, } = this; const attrsSelect = { @@ -382,6 +500,10 @@ export class GcdsSelect { disabled, required, value, + title: selectTitle, + autofocus, + form, + autocomplete, ...inheritedAttributes, }; @@ -394,19 +516,17 @@ export class GcdsSelect { const hintID = hint ? `hint-${selectId} ` : ''; const errorID = errorMessage ? `error-message-${selectId} ` : ''; - attrsSelect['aria-describedby'] = `${hintID}${errorID}${ - attrsSelect['aria-describedby'] - ? `${attrsSelect['aria-describedby']}` - : '' - }`; + attrsSelect['aria-describedby'] = `${hintID}${errorID}${attrsSelect['aria-describedby'] + ? `${attrsSelect['aria-describedby']}` + : '' + }`; } return (
diff --git a/packages/web/src/components/gcds-select/readme.md b/packages/web/src/components/gcds-select/readme.md index 53f2f18b9..0aa9d77a0 100644 --- a/packages/web/src/components/gcds-select/readme.md +++ b/packages/web/src/components/gcds-select/readme.md @@ -11,19 +11,23 @@ A select provides a large list of options for single selection. ## Properties -| Property | Attribute | Description | Type | Default | -| ----------------------- | --------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------- | -| `defaultValue` | `default-value` | The default value is an optional value that gets displayed before the user selects an option. | `string` | `undefined` | -| `disabled` | `disabled` | Specifies if a select element is disabled or not. | `boolean` | `false` | -| `errorMessage` | `error-message` | Error message for an invalid select element. | `string` | `undefined` | -| `hint` | `hint` | Hint displayed below the label. | `string` | `undefined` | -| `label` _(required)_ | `label` | Form field label. | `string` | `undefined` | -| `name` _(required)_ | `name` | Name attribute for select form element. | `string` | `undefined` | -| `required` | `required` | Specifies if a form field is required or not. | `boolean` | `false` | -| `selectId` _(required)_ | `select-id` | Id attribute for a select element. | `string` | `undefined` | -| `validateOn` | `validate-on` | Set event to call validator | `"blur" \| "other" \| "submit"` | `'blur'` | -| `validator` | `validator` | Array of validators | `(string \| ValidatorEntry \| Validator)[]` | `undefined` | -| `value` | `value` | Value for a select element. | `string` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | ----------- | +| `autocomplete` | `autocomplete` | String to have autocomplete enabled. | `string` | `undefined` | +| `autofocus` | `autofocus` | If true, the select will be focused on component render | `boolean` | `undefined` | +| `defaultValue` | `default-value` | The default value is an optional value that gets displayed before the user selects an option. | `string` | `undefined` | +| `disabled` | `disabled` | Specifies if a select element is disabled or not. | `boolean` | `false` | +| `errorMessage` | `error-message` | Error message for an invalid select element. | `string` | `undefined` | +| `form` | `form` | The ID of the form that the select field belongs to. | `string` | `undefined` | +| `hint` | `hint` | Hint displayed below the label. | `string` | `undefined` | +| `label` _(required)_ | `label` | Form field label. | `string` | `undefined` | +| `name` _(required)_ | `name` | Name attribute for select form element. | `string` | `undefined` | +| `required` | `required` | Specifies if a form field is required or not. | `boolean` | `false` | +| `selectId` _(required)_ | `select-id` | Id attribute for a select element. | `string` | `undefined` | +| `validateOn` | `validate-on` | Set event to call validator | `"blur" \| "other" \| "submit"` | `'blur'` | +| `validator` | `validator` | Array of validators | `(string \| ValidatorEntry \| Validator)[]` | `undefined` | +| `validity` | `validity` | Read-only property of the select, returns a ValidityState object that represents the validity states this element is in. | `ValidityState` | `undefined` | +| `value` | `value` | Value for a select element. | `string` | `undefined` | ## Events @@ -40,6 +44,26 @@ A select provides a large list of options for single selection. ## Methods +### `checkValidity() => Promise` + +Check the validity of gcds-select + +#### Returns + +Type: `Promise` + + + +### `getValidationMessage() => Promise` + +Get validationMessage of gcds-select + +#### Returns + +Type: `Promise` + + + ### `validate() => Promise` Call any active validators diff --git a/packages/web/src/components/gcds-select/stories/gcds-select.stories.tsx b/packages/web/src/components/gcds-select/stories/gcds-select.stories.tsx index 570f53c20..5da6f7097 100644 --- a/packages/web/src/components/gcds-select/stories/gcds-select.stories.tsx +++ b/packages/web/src/components/gcds-select/stories/gcds-select.stories.tsx @@ -27,6 +27,28 @@ export default { required: true, }, }, + autocomplete: { + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '-' }, + }, + }, + autofocus: { + control: { type: 'select' }, + options: [false, true], + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + form: { + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '-' }, + }, + }, name: { name: 'name', control: 'text', @@ -163,6 +185,9 @@ const Template = args => ${args.required ? `required` : null} ${args.errorMessage ? `error-message="${args.errorMessage}"` : null} ${args.disabled ? `disabled` : null} + ${args.autocomplete ? `autocomplete="${args.autocomplete}"` : null} + ${args.autofocus ? `autofocus` : null} + ${args.form ? `form="${args.form}"` : null} ${args.validateOn != 'blur' ? `validate-on="${args.validateOn}"` : null} ${args.lang != 'en' ? `lang="${args.lang}"` : null} > @@ -180,6 +205,9 @@ const Template = args => ${args.required ? `required` : null} ${args.errorMessage ? `errorMessage="${args.errorMessage}"` : null} ${args.disabled ? `disabled` : null} + ${args.autocomplete ? `autocomplete="${args.autocomplete}"` : null} + ${args.autofocus ? `autofocus` : null} + ${args.form ? `form="${args.form}"` : null} ${args.validateOn != 'blur' ? `validateOn="${args.validateOn}"` : null} ${args.lang != 'en' ? `lang="${args.lang}"` : null} > @@ -198,6 +226,9 @@ const TemplatePlayground = args => ` ${args.required ? `required` : null} ${args.errorMessage ? `error-message="${args.errorMessage}"` : null} ${args.disabled ? `disabled` : null} + ${args.autocomplete ? `autocomplete="${args.autocomplete}"` : null} + ${args.autofocus ? `autofocus` : null} + ${args.form ? `form="${args.form}"` : null} ${args.validateOn != 'blur' ? `validate-on="${args.validateOn}"` : null} ${args.lang != 'en' ? `lang="${args.lang}"` : null} > @@ -221,6 +252,9 @@ Default.args = { lang: 'en', validateOn: 'blur', default: selectOptions, + autofocus: false, + autocomplete: '', + form: '', }; // ------ Select states ------ @@ -236,6 +270,9 @@ Disabled.args = { lang: 'en', validateOn: 'blur', default: selectOptions, + autofocus: false, + autocomplete: '', + form: '', }; export const Error = Template.bind({}); @@ -250,6 +287,9 @@ Error.args = { lang: 'en', validateOn: 'blur', default: selectOptions, + autofocus: false, + autocomplete: '', + form: '', }; export const Required = Template.bind({}); @@ -263,6 +303,41 @@ Required.args = { lang: 'en', validateOn: 'blur', default: selectOptions, + autofocus: false, + autocomplete: '', + form: '', +}; + +export const Autocomplete = Template.bind({}); +Autocomplete.args = { + selectId: 'select-required', + label: 'Label', + name: 'select', + hint: 'Hint / Example message.', + defaultValue: 'Select option.', + required: false, + lang: 'en', + validateOn: 'blur', + default: selectOptions, + autofocus: false, + autocomplete: 'on', + form: '', +}; + +export const Form = Template.bind({}); +Form.args = { + selectId: 'select-required', + label: 'Label', + name: 'select', + hint: 'Hint / Example message.', + defaultValue: 'Select option.', + required: false, + lang: 'en', + validateOn: 'blur', + default: selectOptions, + autofocus: false, + autocomplete: '', + form: 'formID', }; // ------ Select without default value ------ @@ -276,6 +351,9 @@ WithoutDefaultValue.args = { lang: 'en', validateOn: 'blur', default: selectOptions, + autofocus: false, + autocomplete: '', + form: '', }; // ------ Select events & properties ------ @@ -294,6 +372,9 @@ Props.args = { lang: 'en', validateOn: 'blur', default: selectOptions, + autofocus: false, + autocomplete: '', + form: '', }; // ------ Select playground ------ @@ -312,4 +393,7 @@ Playground.args = { lang: 'en', validateOn: 'blur', default: selectOptions, + autofocus: false, + autocomplete: '', + form: '', }; diff --git a/packages/web/src/components/gcds-select/stories/overview.mdx b/packages/web/src/components/gcds-select/stories/overview.mdx index cbec992b5..855c47505 100644 --- a/packages/web/src/components/gcds-select/stories/overview.mdx +++ b/packages/web/src/components/gcds-select/stories/overview.mdx @@ -43,6 +43,16 @@ A list of options with a single-option choice. +### Native properties + +#### Autocomplete + + + +#### Form + + + ## Resources {/* prettier-ignore */} diff --git a/packages/web/src/components/gcds-select/test/gcds-select.e2e.ts b/packages/web/src/components/gcds-select/test/gcds-select.e2e.ts index a8235abe1..f52b04f09 100644 --- a/packages/web/src/components/gcds-select/test/gcds-select.e2e.ts +++ b/packages/web/src/components/gcds-select/test/gcds-select.e2e.ts @@ -134,6 +134,29 @@ test.describe('gcds-select', () => { expect(errorMessage).toEqual(''); }); + + test('HTML validity', async ({ page }) => { + const element = await page.locator('gcds-select'); + + // Wait for element to attach and become visible, allowing up to 10s + await element.waitFor({ state: 'attached' }); + await element.waitFor({ state: 'visible' }); + await element.waitFor({ timeout: 10000 }); + + let validity = await element.evaluate(el => + (el as HTMLGcdsSelectElement).checkValidity(), + ); + + expect(validity).toBe(false); + + await element.locator('select').selectOption('2'); + + validity = await element.evaluate(el => + (el as HTMLGcdsSelectElement).checkValidity(), + ); + + expect(validity).toBe(true); + }); }); /** diff --git a/packages/web/src/components/gcds-select/test/gcds-select.spec.tsx b/packages/web/src/components/gcds-select/test/gcds-select.spec.tsx index e15cb8ff7..88eb443bd 100644 --- a/packages/web/src/components/gcds-select/test/gcds-select.spec.tsx +++ b/packages/web/src/components/gcds-select/test/gcds-select.spec.tsx @@ -246,4 +246,172 @@ describe('gcds-select', () => { `); }); + + /** + * Select with autocomplete + */ + it('renders with autocomplete', async () => { + const page = await newSpecPage({ + components: [GcdsSelect], + html: ``, + }); + expect(page.root).toEqualHtml(` + + +
+ + +
+
+
+ `); + }); + + /** + * Select with form attribute + */ + it('renders with form attribute', async () => { + const page = await newSpecPage({ + components: [GcdsSelect], + html: ``, + }); + expect(page.root).toEqualHtml(` + + +
+ + +
+
+
+ `); + }); + + /** + * Select with autofocus + */ + it('renders with autofocus', async () => { + const page = await newSpecPage({ + components: [GcdsSelect], + html: ``, + }); + expect(page.root).toEqualHtml(` + + +
+ + +
+
+
+ `); + }); + + /** + * Select with assigned value attribute + */ + it('renders select with value attribute', async () => { + const page = await newSpecPage({ + components: [GcdsSelect], + html: ` + + + + + + `, + }); + expect(page.root).toEqualHtml(` + + +
+ + +
+
+ + + +
+ `); + ; + expect(page.root.value).toBe('2'); + }); + + /** + * Select with selected option + */ + it('renders select with selected option', async () => { + const page = await newSpecPage({ + components: [GcdsSelect], + html: ` + + + + + + `, + }); + expect(page.root).toEqualHtml(` + + +
+ + +
+
+ + + +
+ `); + ; + expect(page.root.value).toBe('2'); + }); + + /** + * Select with invalid assigned value + */ + it('renders select with invalid assigned value', async () => { + const page = await newSpecPage({ + components: [GcdsSelect], + html: ` + + + + + + `, + }); + expect(page.root).toEqualHtml(` + + +
+ + +
+
+ + + +
+ `); + ; + expect(page.root.value).toBeNull(); + }); });