diff --git a/package-lock.json b/package-lock.json index d9ff031fe..de19ab0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2819,9 +2819,9 @@ "link": true }, "node_modules/@cdssnc/gcds-tokens": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@cdssnc/gcds-tokens/-/gcds-tokens-1.16.0.tgz", - "integrity": "sha512-fYitZ3CWpu6v4UOY2D2qSR0tcExys8yY5qeEwLb07UoiGPUt/oS+26TXgvcRZ04QbNLNzev2kziSe9Q4cLKUkA==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@cdssnc/gcds-tokens/-/gcds-tokens-1.16.1.tgz", + "integrity": "sha512-Z9u2WhGfZcREgYoEfWTbz0OFXLiKVpZEgDWT1RZVODB+n8PThKEPBBu3aPnQkz40yIYX10m5G3bTP7NdT5d4+w==", "dev": true }, "node_modules/@cnakazawa/watch": { @@ -48548,7 +48548,7 @@ "@babel/core": "^7.20.12", "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.21.0", - "@cdssnc/gcds-tokens": "^1.16.0", + "@cdssnc/gcds-tokens": "^1.16.1", "@fortawesome/fontawesome-free": "^6.3.0", "@stencil/angular-output-target": "file:../../utils/angular-output-target", "@stencil/postcss": "^2.1.0", diff --git a/packages/angular/src/lib/stencil-generated/components.ts b/packages/angular/src/lib/stencil-generated/components.ts index ce74cdd30..4bfb1abe6 100644 --- a/packages/angular/src/lib/stencil-generated/components.ts +++ b/packages/angular/src/lib/stencil-generated/components.ts @@ -211,6 +211,56 @@ export class GcdsContainer { export declare interface GcdsContainer extends Components.GcdsContainer {} +@ProxyCmp({ + inputs: ['disabled', 'errorMessage', 'format', 'hint', 'legend', 'name', 'required', 'validateOn', 'validator', 'value'], + methods: ['validate'], + outputs: ['gcdsFocus', 'gcdsBlur', 'gcdsInput', 'gcdsChange', 'gcdsError', 'gcdsValid'] +}) +@Component({ + selector: 'gcds-date-input', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['disabled', 'errorMessage', 'format', 'hint', 'legend', 'name', 'required', 'validateOn', 'validator', 'value'],outputs: ['gcdsFocus', 'gcdsBlur', 'gcdsInput', 'gcdsChange', 'gcdsError', 'gcdsValid'], +}) +export class GcdsDateInput { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + proxyOutputs(this, ['gcdsFocus', 'gcdsBlur', 'gcdsInput', 'gcdsChange', 'gcdsError', 'gcdsValid']); + } +} + + +export declare interface GcdsDateInput extends Components.GcdsDateInput { + /** + * Emitted when an element has focus. + */ + gcdsFocus: EventEmitter>; + /** + * Emitted when an element loses focus. + */ + gcdsBlur: EventEmitter>; + /** + * Emitted when the element has received input. + */ + gcdsInput: EventEmitter>; + /** + * Emitted when an element has changed. + */ + gcdsChange: EventEmitter>; + /** + * Emitted when an element has a validation error. + */ + gcdsError: EventEmitter>; + /** + * Emitted when an element has validated. + */ + gcdsValid: EventEmitter>; +} + + @ProxyCmp({ inputs: ['type'] }) diff --git a/packages/angular/src/lib/stencil-generated/index.ts b/packages/angular/src/lib/stencil-generated/index.ts index a40bb704c..3339b43d2 100644 --- a/packages/angular/src/lib/stencil-generated/index.ts +++ b/packages/angular/src/lib/stencil-generated/index.ts @@ -9,6 +9,7 @@ export const DIRECTIVES = [ d.GcdsCard, d.GcdsCheckbox, d.GcdsContainer, + d.GcdsDateInput, d.GcdsDateModified, d.GcdsDetails, d.GcdsErrorMessage, diff --git a/packages/react-ssr/lib/client/render.ts b/packages/react-ssr/lib/client/render.ts index 628af62c4..9371a6c96 100644 --- a/packages/react-ssr/lib/client/render.ts +++ b/packages/react-ssr/lib/client/render.ts @@ -13,6 +13,7 @@ declare global { const delFocusElements = [ 'gcds-button', 'gcds-checkbox', + 'gcds-date-input', 'gcds-fieldset', 'gcds-file-uploader', 'gcds-input', diff --git a/packages/react/src/components/stencil-generated/index.ts b/packages/react/src/components/stencil-generated/index.ts index 257f7fc15..b8dbdbac6 100644 --- a/packages/react/src/components/stencil-generated/index.ts +++ b/packages/react/src/components/stencil-generated/index.ts @@ -15,6 +15,7 @@ export const GcdsButton = /*@__PURE__*/createReactComponent('gcds-card'); export const GcdsCheckbox = /*@__PURE__*/createReactComponent('gcds-checkbox'); export const GcdsContainer = /*@__PURE__*/createReactComponent('gcds-container'); +export const GcdsDateInput = /*@__PURE__*/createReactComponent('gcds-date-input'); export const GcdsDateModified = /*@__PURE__*/createReactComponent('gcds-date-modified'); export const GcdsDetails = /*@__PURE__*/createReactComponent('gcds-details'); export const GcdsErrorMessage = /*@__PURE__*/createReactComponent('gcds-error-message'); diff --git a/packages/vue/lib/components.ts b/packages/vue/lib/components.ts index d18752094..febbf3e48 100644 --- a/packages/vue/lib/components.ts +++ b/packages/vue/lib/components.ts @@ -89,6 +89,26 @@ export const GcdsContainer = /*@__PURE__*/ defineContainer('g ]); +export const GcdsDateInput = /*@__PURE__*/ defineContainer('gcds-date-input', undefined, [ + 'name', + 'legend', + 'format', + 'value', + 'required', + 'hint', + 'errorMessage', + 'disabled', + 'validator', + 'validateOn', + 'gcdsFocus', + 'gcdsBlur', + 'gcdsInput', + 'gcdsChange', + 'gcdsError', + 'gcdsValid' +]); + + export const GcdsDateModified = /*@__PURE__*/ defineContainer('gcds-date-modified', undefined, [ 'type' ]); diff --git a/packages/web/package.json b/packages/web/package.json index 611cced67..2ae399ece 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -45,7 +45,7 @@ "@babel/core": "^7.20.12", "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.21.0", - "@cdssnc/gcds-tokens": "^1.16.0", + "@cdssnc/gcds-tokens": "^1.16.1", "@fortawesome/fontawesome-free": "^6.3.0", "@stencil/angular-output-target": "file:../../utils/angular-output-target", "@stencil/postcss": "^2.1.0", diff --git a/packages/web/src/components.d.ts b/packages/web/src/components.d.ts index b78c9aad4..f1701279b 100644 --- a/packages/web/src/components.d.ts +++ b/packages/web/src/components.d.ts @@ -234,6 +234,54 @@ export namespace Components { */ "tag"?: string; } + interface GcdsDateInput { + /** + * Specifies if the date input is disabled or not. + */ + "disabled"?: boolean; + /** + * Error message displayed below the legend and above form fields. + */ + "errorMessage"?: string; + /** + * Set this property to full to show month, day, and year form elements. Set it to compact to show only the month and year form elements. + */ + "format": 'full' | 'compact'; + /** + * Hint displayed below the legend and above form fields. + */ + "hint"?: string; + /** + * Fieldset legend + */ + "legend": string; + /** + * Name attribute for the date input. + */ + "name": string; + /** + * Specifies if a form field is required or not. + */ + "required"?: boolean; + /** + * Call any active validators + */ + "validate": () => Promise; + /** + * Set event to call validator + */ + "validateOn": 'blur' | 'submit' | 'other'; + /** + * Array of validators + */ + "validator": Array< + string | ValidatorEntry | Validator + >; + /** + * Default value for the date input element. Format: YYYY-MM-DD + */ + "value"?: string; + } interface GcdsDateModified { /** * Set date modified type. Default is date. @@ -1155,6 +1203,10 @@ export interface GcdsCheckboxCustomEvent extends CustomEvent { detail: T; target: HTMLGcdsCheckboxElement; } +export interface GcdsDateInputCustomEvent extends CustomEvent { + detail: T; + target: HTMLGcdsDateInputElement; +} export interface GcdsDetailsCustomEvent extends CustomEvent { detail: T; target: HTMLGcdsDetailsElement; @@ -1286,6 +1338,28 @@ declare global { prototype: HTMLGcdsContainerElement; new (): HTMLGcdsContainerElement; }; + interface HTMLGcdsDateInputElementEventMap { + "gcdsFocus": void; + "gcdsBlur": void; + "gcdsInput": any; + "gcdsChange": any; + "gcdsError": object; + "gcdsValid": object; + } + interface HTMLGcdsDateInputElement extends Components.GcdsDateInput, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLGcdsDateInputElement, ev: GcdsDateInputCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLGcdsDateInputElement, ev: GcdsDateInputCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLGcdsDateInputElement: { + prototype: HTMLGcdsDateInputElement; + new (): HTMLGcdsDateInputElement; + }; interface HTMLGcdsDateModifiedElement extends Components.GcdsDateModified, HTMLStencilElement { } var HTMLGcdsDateModifiedElement: { @@ -1664,6 +1738,7 @@ declare global { "gcds-card": HTMLGcdsCardElement; "gcds-checkbox": HTMLGcdsCheckboxElement; "gcds-container": HTMLGcdsContainerElement; + "gcds-date-input": HTMLGcdsDateInputElement; "gcds-date-modified": HTMLGcdsDateModifiedElement; "gcds-details": HTMLGcdsDetailsElement; "gcds-error-message": HTMLGcdsErrorMessageElement; @@ -1958,6 +2033,74 @@ declare namespace LocalJSX { */ "tag"?: string; } + interface GcdsDateInput { + /** + * Specifies if the date input is disabled or not. + */ + "disabled"?: boolean; + /** + * Error message displayed below the legend and above form fields. + */ + "errorMessage"?: string; + /** + * Set this property to full to show month, day, and year form elements. Set it to compact to show only the month and year form elements. + */ + "format": 'full' | 'compact'; + /** + * Hint displayed below the legend and above form fields. + */ + "hint"?: string; + /** + * Fieldset legend + */ + "legend": string; + /** + * Name attribute for the date input. + */ + "name": string; + /** + * Emitted when an element loses focus. + */ + "onGcdsBlur"?: (event: GcdsDateInputCustomEvent) => void; + /** + * Emitted when an element has changed. + */ + "onGcdsChange"?: (event: GcdsDateInputCustomEvent) => void; + /** + * Emitted when an element has a validation error. + */ + "onGcdsError"?: (event: GcdsDateInputCustomEvent) => void; + /** + * Emitted when an element has focus. + */ + "onGcdsFocus"?: (event: GcdsDateInputCustomEvent) => void; + /** + * Emitted when the element has received input. + */ + "onGcdsInput"?: (event: GcdsDateInputCustomEvent) => void; + /** + * Emitted when an element has validated. + */ + "onGcdsValid"?: (event: GcdsDateInputCustomEvent) => void; + /** + * Specifies if a form field is required or not. + */ + "required"?: boolean; + /** + * Set event to call validator + */ + "validateOn"?: 'blur' | 'submit' | 'other'; + /** + * Array of validators + */ + "validator"?: Array< + string | ValidatorEntry | Validator + >; + /** + * Default value for the date input element. Format: YYYY-MM-DD + */ + "value"?: string; + } interface GcdsDateModified { /** * Set date modified type. Default is date. @@ -3026,6 +3169,7 @@ declare namespace LocalJSX { "gcds-card": GcdsCard; "gcds-checkbox": GcdsCheckbox; "gcds-container": GcdsContainer; + "gcds-date-input": GcdsDateInput; "gcds-date-modified": GcdsDateModified; "gcds-details": GcdsDetails; "gcds-error-message": GcdsErrorMessage; @@ -3072,6 +3216,7 @@ declare module "@stencil/core" { "gcds-card": LocalJSX.GcdsCard & JSXBase.HTMLAttributes; "gcds-checkbox": LocalJSX.GcdsCheckbox & JSXBase.HTMLAttributes; "gcds-container": LocalJSX.GcdsContainer & JSXBase.HTMLAttributes; + "gcds-date-input": LocalJSX.GcdsDateInput & JSXBase.HTMLAttributes; "gcds-date-modified": LocalJSX.GcdsDateModified & JSXBase.HTMLAttributes; "gcds-details": LocalJSX.GcdsDetails & JSXBase.HTMLAttributes; "gcds-error-message": LocalJSX.GcdsErrorMessage & JSXBase.HTMLAttributes; diff --git a/packages/web/src/components/gcds-date-input/gcds-date-input.css b/packages/web/src/components/gcds-date-input/gcds-date-input.css new file mode 100644 index 000000000..eec640463 --- /dev/null +++ b/packages/web/src/components/gcds-date-input/gcds-date-input.css @@ -0,0 +1,48 @@ +@layer reset, default, hint, error; + +@layer reset { + :host { + display: block; + } +} + +@layer default { + :host { + .gcds-date-input__fieldset { + --gcds-fieldset-font-desktop: var(--gcds-date-input-fieldset-font-desktop); + --gcds-fieldset-font-mobile: var(--gcds-date-input-fieldset-font-mobile); + --gcds-fieldset-legend-margin: var(--gcds-date-input-fieldset-margin); + } + + .gcds-date-input__month, + .gcds-date-input__year, + .gcds-date-input__day { + display: inline-block; + margin-inline-end: var(--gcds-date-input-margin); + + --gcds-label-font-desktop: var(--gcds-date-input-label-font-desktop); + --gcds-label-font-mobile: var(--gcds-date-input-label-font-mobile ); + } + } +} + +@layer hint { + :host { + .gcds-date-input--hint { + --gcds-fieldset-legend-margin: var(--gcds-date-input-fieldset-hint-margin); + } + } +} + +@layer error { + :host { + .gcds-date-input--error { + --gcds-fieldset-legend-margin: var(--gcds-date-input-fieldset-error-margin ); + } + + gcds-input.gcds-date-input--error::part(input), + gcds-select.gcds-date-input--error::part(select) { + border-color: var(--gcds-date-input-danger-border); + } + } +} diff --git a/packages/web/src/components/gcds-date-input/gcds-date-input.tsx b/packages/web/src/components/gcds-date-input/gcds-date-input.tsx new file mode 100644 index 000000000..4f036a52c --- /dev/null +++ b/packages/web/src/components/gcds-date-input/gcds-date-input.tsx @@ -0,0 +1,541 @@ +import { + Component, + Host, + Element, + AttachInternals, + Prop, + State, + Event, + EventEmitter, + Watch, + Method, + Listen, + h, +} from '@stencil/core'; +import { + assignLanguage, + observerConfig, + isValidDate, + logError, +} from '../../utils/utils'; +import { + Validator, + defaultValidator, + ValidatorEntry, + getValidator, + requiredValidator, +} from '../../validators'; +import i18n from './i18n/i18n'; + +@Component({ + tag: 'gcds-date-input', + styleUrl: 'gcds-date-input.css', + shadow: { delegatesFocus: true }, + formAssociated: true, +}) +export class GcdsDateInput { + @Element() el: HTMLElement; + + @AttachInternals() + internals: ElementInternals; + + private initialValue?: string; + + _validator: Validator = defaultValidator; + + /** + * Name attribute for the date input. + */ + @Prop() name!: string; + @Watch('name') + validateName() { + if (!this.name) { + this.errors.push('name'); + } else if (this.errors.includes('name')) { + this.errors.splice(this.errors.indexOf('name'), 1); + } + } + + /** + * Fieldset legend + */ + @Prop() legend!: string; + @Watch('legend') + validateLegend() { + if (!this.legend) { + this.errors.push('legend'); + } else if (this.errors.includes('legend')) { + this.errors.splice(this.errors.indexOf('legend'), 1); + } + } + + /** + * Set this property to full to show month, day, and year form elements. Set it to compact to show only the month and year form elements. + */ + @Prop() format!: 'full' | 'compact'; + @Watch('format') + validateFormat() { + if (!this.format || (this.format != 'full' && this.format != 'compact')) { + this.errors.push('format'); + } else if (this.errors.includes('format')) { + this.errors.splice(this.errors.indexOf('format'), 1); + } + } + + /** + * Default value for the date input element. Format: YYYY-MM-DD + */ + @Prop({ mutable: true }) value?: string; + @Watch('value') + validateValue() { + if (this.value && !isValidDate(this.value)) { + this.errors.push('value'); + this.value = ''; + console.error( + `${i18n['en'].valueError}${i18n['en'][`valueFormat${this.format}`]} | ${i18n['fr'].valueError}${i18n['fr'][`valueFormat${this.format}`]}`, + ); + } else if (this.errors.includes('value')) { + this.errors.splice(this.errors.indexOf('value'), 1); + } + } + + /** + * Specifies if a form field is required or not. + */ + @Prop() required?: boolean = false; + + /** + * Hint displayed below the legend and above form fields. + */ + @Prop() hint?: string; + + /** + * Error message displayed below the legend and above form fields. + */ + @Prop({ mutable: true }) errorMessage?: string; + + /** + * Specifies if the date input is disabled or not. + */ + @Prop({ mutable: true }) disabled?: boolean = false; + + /** + * Array of validators + */ + @Prop({ mutable: true }) validator: Array< + string | ValidatorEntry | Validator + >; + @Watch('validator') + validateValidator() { + if (this.validator && !this.validateOn) { + this.validateOn = 'blur'; + } + } + + /** + * Set event to call validator + */ + @Prop({ mutable: true }) validateOn: 'blur' | 'submit' | 'other'; + + /** + * States + */ + + /** + * State to track individual month value + */ + @State() monthValue: string = ''; + + /** + * State to track individual month value + */ + @State() dayValue: string = ''; + + /** + * State to track individual month value + */ + @State() yearValue: string = ''; + + /** + * Specifies if the date input is invalid. + */ + @State() hasError: object = { + day: false, + month: false, + year: false, + }; + + /** + * State to track validation on properties + * Contains a list of properties that have an error associated with them + */ + @State() errors: Array = []; + + /** + * Language of rendered date input + */ + @State() lang: string; + + /** + * Events + */ + + /** + * Emitted when an element has focus. + */ + @Event() gcdsFocus!: EventEmitter; + + /** + * Emitted when an element loses focus. + */ + @Event() gcdsBlur!: EventEmitter; + + private onBlur = () => { + if (this.validateOn == 'blur') { + this.validate(); + } + }; + + /** + * Emitted when the element has received input. + */ + @Event() gcdsInput: EventEmitter; + + /** + * Emitted when an element has changed. + */ + @Event() gcdsChange: EventEmitter; + + /** + * Emitted when an element has a validation error. + */ + @Event() gcdsError!: EventEmitter; + + /** + * Emitted when an element has validated. + */ + @Event() gcdsValid!: EventEmitter; + + /** + * Call any active validators + */ + @Method() + async validate() { + const validationResult = this._validator.validate( + this.format === 'full' + ? `${this.yearValue}-${this.monthValue}-${this.dayValue}` + : `${this.yearValue}-${this.monthValue}`, + ); + if (!validationResult.valid) { + this.errorMessage = validationResult.reason[this.lang]; + this.hasError = { ...validationResult.errors }; + this.gcdsError.emit({ + message: `${this.legend} - ${this.errorMessage}`, + errors: validationResult.errors, + }); + } else { + this.errorMessage = ''; + this.gcdsValid.emit(); + this.hasError = { + day: false, + month: false, + year: false, + }; + } + } + + /* + * Event listeners + */ + + @Listen('submit', { target: 'document' }) + async submitListener(e) { + if (e.target == this.el.closest('form')) { + if (this.validateOn && this.validateOn != 'other') { + this.validate(); + } + + for (const key in this.hasError) { + if (this.hasError[key]) { + e.preventDefault(); + } + } + } + } + + /* + * Form internal functions + */ + + formResetCallback() { + if (this.value != this.initialValue) { + this.internals.setFormValue(this.initialValue); + this.value = this.initialValue; + } + } + + formStateRestoreCallback(state) { + this.internals.setFormValue(state); + this.value = state; + } + + /* + * Observe lang attribute change + */ + updateLang() { + const observer = new MutationObserver(mutations => { + if (mutations[0].oldValue != this.el.lang) { + this.lang = this.el.lang; + } + }); + observer.observe(this.el, observerConfig); + } + + /* + * Handle input event to update state + */ + private handleInput = (e, type) => { + const val = e.target && e.target.value; + + if (type === 'year') { + this.yearValue = val; + } else if (type === 'month') { + this.monthValue = val; + } else if (type === 'day') { + this.dayValue = val; + } + + this.setValue(); + + if (e.type === 'change') { + const changeEvt = new e.constructor(e.type, e); + this.el.dispatchEvent(changeEvt); + } + }; + + /** + * Logic to combine all input values together based on format + */ + private setValue() { + const { yearValue, dayValue, monthValue, format } = this; + + // All form elements have something entered + if (yearValue && monthValue && dayValue && format == 'full') { + // Is the combined value a valid date + if (isValidDate(`${yearValue}-${monthValue}-${dayValue}`, format)) { + this.value = `${yearValue}-${monthValue}-${dayValue}`; + this.internals.setFormValue(this.value); + } else { + this.value = null; + this.internals.setFormValue(null); + + return false; + } + } else if (yearValue && monthValue && format == 'compact') { + // Is the combined value a valid date + if (isValidDate(`${yearValue}-${monthValue}`, format)) { + this.value = `${yearValue}-${monthValue}`; + this.internals.setFormValue(this.value); + } else { + this.value = null; + this.internals.setFormValue(null); + + return false; + } + } else { + this.value = null; + this.internals.setFormValue(null); + + return false; + } + + return true; + } + + /** + * Split value into parts depending on format + */ + private splitFormValue() { + if (this.value && isValidDate(this.value, this.format)) { + if (this.format == 'compact') { + let splitValue = this.value.split('-'); + this.yearValue = splitValue[0]; + this.monthValue = splitValue[1]; + } else { + let splitValue = this.value.split('-'); + this.yearValue = splitValue[0]; + this.monthValue = splitValue[1]; + this.dayValue = splitValue[2]; + } + } + } + + /** + * Format day input value to add 0 to single digit values + */ + private formatDay(e) { + if (!isNaN(e.target.value) && e.target.value.length === 1) { + this.dayValue = '0' + e.target.value; + } + } + + private validateRequiredProps() { + this.validateName(); + this.validateLegend(); + this.validateFormat(); + + if ( + this.errors.includes('name') || + this.errors.includes('legend') || + this.errors.includes('format') + ) { + return false; + } + return true; + } + + async componentWillLoad() { + // Define lang attribute + this.lang = assignLanguage(this.el); + + this.updateLang(); + this.validateValidator(); + + // Assign required validator if needed + requiredValidator(this.el, 'date-input'); + + if (this.validator) { + this._validator = getValidator(this.validator); + } + + let valid = this.validateRequiredProps(); + + if (!valid) { + logError('gcds-date-input', this.errors); + } + + this.validateValue(); + if (this.value && isValidDate(this.value)) { + this.splitFormValue(); + this.setValue(); + + this.initialValue = this.value; + } + } + + componentWillUpdate() { + if (this.validator) { + this._validator = getValidator(this.validator); + } + } + + render() { + const { + legend, + name, + format, + required, + hint, + errorMessage, + disabled, + lang, + hasError, + } = this; + + let requiredAttr = {}; + + if (required) { + requiredAttr['aria-required'] = 'true'; + } + + // Array of months 01 - 12 + const options = Array.from({ length: 12 }, (_, i) => + i + 1 < 10 ? `0${i + 1}` : `${i + 1}`, + ); + + const month = ( + this.handleInput(e, 'month')} + onChange={e => this.handleInput(e, 'month')} + value={this.monthValue} + class={`gcds-date-input__month ${hasError['month'] ? 'gcds-date-input--error' : ''}`} + {...requiredAttr} + aria-invalid={hasError['month'].toString()} + aria-description={hasError['month'] && errorMessage} + > + {options.map(option => ( + + ))} + + ); + + const year = ( + this.handleInput(e, 'year')} + onChange={e => this.handleInput(e, 'year')} + class={`gcds-date-input__year ${hasError['year'] ? 'gcds-date-input--error' : ''}`} + {...requiredAttr} + aria-invalid={hasError['year'].toString()} + aria-description={hasError['year'] && errorMessage} + > + ); + + const day = ( + this.handleInput(e, 'day')} + onChange={e => { + this.handleInput(e, 'day'); + this.formatDay(e); + }} + class={`gcds-date-input__day ${hasError['day'] ? 'gcds-date-input--error' : ''}`} + {...requiredAttr} + aria-invalid={hasError['day'].toString()} + aria-description={hasError['day'] && errorMessage} + > + ); + + return ( + this.onBlur()}> + {this.validateRequiredProps() && ( + + {format == 'compact' + ? [month, year] + : lang == 'en' + ? [month, day, year] + : [day, month, year]} + + )} + + ); + } +} diff --git a/packages/web/src/components/gcds-date-input/i18n/i18n.js b/packages/web/src/components/gcds-date-input/i18n/i18n.js new file mode 100644 index 000000000..f91d044ac --- /dev/null +++ b/packages/web/src/components/gcds-date-input/i18n/i18n.js @@ -0,0 +1,50 @@ +const I18N = { + en: { + year: 'Year', + month: 'Month', + day: 'Day', + selectmonth: 'Select a month', + months: { + '01': 'January', + '02': 'February', + '03': 'March', + '04': 'April', + '05': 'May', + '06': 'June', + '07': 'July', + '08': 'August', + '09': 'September', + '10': 'October', + '11': 'November', + '12': 'December', + }, + valueError: 'gcds-date-input: Value attribute contains an invalid date format. Expected format: ', + valueFormatfull: 'YYYY-MM-DD', + valueFormatcompact: 'YYYY-MM' + }, + fr: { + year: 'Année', + month: 'Mois', + day: 'Jour', + selectmonth: 'Sélectionnez un mois', + months : { + '01': 'janvier', + '02': 'février', + '03': 'mars', + '04': 'avril', + '05': 'mai', + '06': 'juin', + '07': 'juillet', + '08': 'août', + '09': 'septembre', + '10': 'octobre', + '11': 'novembre', + '12': 'décembre', + }, + valueError: 'gcds-date-input: Value attribute contains an invalid date format. Expected format: ', + valueFormatfull: 'YYYY-MM-DD', + valueFormatcompact: 'YYYY-MM' + }, +}; + +export default I18N; diff --git a/packages/web/src/components/gcds-date-input/readme.md b/packages/web/src/components/gcds-date-input/readme.md new file mode 100644 index 000000000..4617dd36a --- /dev/null +++ b/packages/web/src/components/gcds-date-input/readme.md @@ -0,0 +1,79 @@ +# gcds-date-input + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| --------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------- | +| `disabled` | `disabled` | Specifies if the date input is disabled or not. | `boolean` | `false` | +| `errorMessage` | `error-message` | Error message displayed below the legend and above form fields. | `string` | `undefined` | +| `format` _(required)_ | `format` | Set this property to full to show month, day, and year form elements. Set it to compact to show only the month and year form elements. | `"compact" \| "full"` | `undefined` | +| `hint` | `hint` | Hint displayed below the legend and above form fields. | `string` | `undefined` | +| `legend` _(required)_ | `legend` | Fieldset legend | `string` | `undefined` | +| `name` _(required)_ | `name` | Name attribute for the date input. | `string` | `undefined` | +| `required` | `required` | Specifies if a form field is required or not. | `boolean` | `false` | +| `validateOn` | `validate-on` | Set event to call validator | `"blur" \| "other" \| "submit"` | `undefined` | +| `validator` | -- | Array of validators | `(string \| ValidatorEntry \| Validator)[]` | `undefined` | +| `value` | `value` | Default value for the date input element. Format: YYYY-MM-DD | `string` | `undefined` | + + +## Events + +| Event | Description | Type | +| ------------ | ----------------------------------------------- | --------------------- | +| `gcdsBlur` | Emitted when an element loses focus. | `CustomEvent` | +| `gcdsChange` | Emitted when an element has changed. | `CustomEvent` | +| `gcdsError` | Emitted when an element has a validation error. | `CustomEvent` | +| `gcdsFocus` | Emitted when an element has focus. | `CustomEvent` | +| `gcdsInput` | Emitted when the element has received input. | `CustomEvent` | +| `gcdsValid` | Emitted when an element has validated. | `CustomEvent` | + + +## Methods + +### `validate() => Promise` + +Call any active validators + +#### Returns + +Type: `Promise` + + + + +## Dependencies + +### Depends on + +- [gcds-select](../gcds-select) +- [gcds-input](../gcds-input) +- [gcds-fieldset](../gcds-fieldset) + +### Graph +```mermaid +graph TD; + gcds-date-input --> gcds-select + gcds-date-input --> gcds-input + gcds-date-input --> gcds-fieldset + gcds-select --> gcds-label + gcds-select --> gcds-hint + gcds-select --> gcds-error-message + gcds-hint --> gcds-text + gcds-error-message --> gcds-text + gcds-error-message --> gcds-icon + gcds-input --> gcds-label + gcds-input --> gcds-hint + gcds-input --> gcds-error-message + gcds-fieldset --> gcds-hint + gcds-fieldset --> gcds-error-message + style gcds-date-input fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/web/src/components/gcds-date-input/stories/gcds-date-input.stories.tsx b/packages/web/src/components/gcds-date-input/stories/gcds-date-input.stories.tsx new file mode 100644 index 000000000..9fab7cda4 --- /dev/null +++ b/packages/web/src/components/gcds-date-input/stories/gcds-date-input.stories.tsx @@ -0,0 +1,337 @@ +import { + langProp, + validatorProps, +} from '../../../utils/storybook/component-properties'; + +export default { + title: 'Components/Date Input', + + argTypes: { + // Props + name: { + name: 'name', + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '-' }, + }, + type: { + required: true, + }, + }, + format: { + control: { type: 'select' }, + options: ['full', 'compact'], + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: '-' }, + }, + type: { + required: true, + }, + }, + disabled: { + control: { type: 'select' }, + options: [false, true], + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + errorMessage: { + name: 'error-message', + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '-' }, + }, + }, + hint: { + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '-' }, + }, + }, + legend: { + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '-' }, + }, + type: { + required: true, + }, + }, + required: { + control: { type: 'select' }, + options: [false, true], + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + value: { + control: 'text', + description: 'Full: YYYY-MM-DD, compact: YYYY-MM', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '-' }, + }, + }, + + ...validatorProps, + ...langProp, + + // Events + gcdsChange: { + action: 'change', + table: { + category: 'Events | Événements', + }, + }, + gcdsInput: { + action: 'input', + table: { + category: 'Events | Événements', + }, + }, + gcdsFocus: { + action: 'focus', + table: { + category: 'Events | Événements', + }, + }, + gcdsBlur: { + action: 'blur', + table: { + category: 'Events | Événements', + }, + }, + }, +}; + +const Template = args => + ` + + + + + + + +`.replace(/\s\snull\n/g, ''); + +const TemplatePlayground = args => ` + + +`; + +// ------ Date input default ------ + +export const Default = Template.bind({}); +Default.args = { + name: 'example-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: false, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Full English ------ + +export const FullEN = Template.bind({}); +FullEN.args = { + name: 'FullEN-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: false, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Full French ------ + +export const FullFR = Template.bind({}); +FullFR.args = { + name: 'FullFR-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: false, + lang: 'fr', + validateOn: '', +}; + +// ------ Date input Format Compact English ------ + +export const CompactEN = Template.bind({}); +CompactEN.args = { + name: 'CompactEN-default', + legend: 'Date input', + format: 'compact', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: false, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Compact French ------ + +export const CompactFR = Template.bind({}); +CompactFR.args = { + name: 'CompactFR-default', + legend: 'Date input', + format: 'compact', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: false, + lang: 'fr', + validateOn: '', +}; + +// ------ Date input Format Required ------ + +export const Required = Template.bind({}); +Required.args = { + name: 'required-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: '', + required: true, + disabled: false, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Hint ------ + +export const DefaultState = Template.bind({}); +DefaultState.args = { + name: 'hint-default', + legend: 'Date input', + format: 'full', + value: '', + hint: 'Hint / example message.', + errorMessage: '', + required: false, + disabled: false, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Error ------ + +export const Error = Template.bind({}); +Error.args = { + name: 'error-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: 'Enter the date.', + required: false, + disabled: false, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Disabled ------ + +export const Disabled = Template.bind({}); +Disabled.args = { + name: 'disabled-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: true, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Props ------ + +export const Props = Template.bind({}); +Props.args = { + name: 'props-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: false, + lang: 'en', + validateOn: '', +}; + +// ------ Date input Format Playground ------ + +export const Playground = TemplatePlayground.bind({}); +Playground.args = { + name: 'playground-default', + legend: 'Date input', + format: 'full', + value: '', + hint: '', + errorMessage: '', + required: false, + disabled: false, + lang: 'en', + validateOn: '', +}; + diff --git a/packages/web/src/components/gcds-date-input/stories/overview.mdx b/packages/web/src/components/gcds-date-input/stories/overview.mdx new file mode 100644 index 000000000..3442c083f --- /dev/null +++ b/packages/web/src/components/gcds-date-input/stories/overview.mdx @@ -0,0 +1,68 @@ +import { Meta, Canvas, Story } from '@storybook/blocks'; +import * as DateInput from './gcds-date-input.stories'; + + + +# date input
`` + +_Also called: dates, dateinput, date, memorable date._ + +A date input is a space to enter a known date. + + + + +## Examples + +
+ +### Format + +#### Full - English + + + +#### Full - French + + + +#### Compact - English + + + +#### Compact - French + + + +### State + +#### Default + + + +#### Required + + + +#### Error message + + + +#### Disabled + + + +## Resources + +{/* prettier-ignore */} +
    +
  • + Guidance +
  • +
  • + Github +
  • +
  • + Figma +
  • +
diff --git a/packages/web/src/components/gcds-date-input/stories/properties.mdx b/packages/web/src/components/gcds-date-input/stories/properties.mdx new file mode 100644 index 000000000..8757b5ae9 --- /dev/null +++ b/packages/web/src/components/gcds-date-input/stories/properties.mdx @@ -0,0 +1,15 @@ +import { Meta, Canvas, Controls } from '@storybook/blocks'; +import * as DateInput from './gcds-date-input.stories'; + + + +{!(new URLSearchParams(window.location.search)).get('demo') &&

Events & properties

} + + + + diff --git a/packages/web/src/components/gcds-date-input/test/gcds-date-input.e2e.ts b/packages/web/src/components/gcds-date-input/test/gcds-date-input.e2e.ts new file mode 100644 index 000000000..e93bbe190 --- /dev/null +++ b/packages/web/src/components/gcds-date-input/test/gcds-date-input.e2e.ts @@ -0,0 +1,350 @@ +import { newE2EPage } from '@stencil/core/testing'; +const { AxePuppeteer } = require('@axe-core/puppeteer'); +import { dateInputErrorMessage } from '../../../validators/input-validators/input-validators'; + +describe('gcds-date-input', () => { + it('renders', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + const element = await page.find('gcds-date-input'); + expect(element).toHaveClass('hydrated'); + }); + + it('submit value in full format', async () => { + const page = await newE2EPage(); + await page.setContent( + `
+ + +
+ `, + ); + + await page.waitForChanges(); + + // Select March + await page.select('gcds-date-input >>> gcds-select >>> select', '03'); + + // Type 3 + await page.type( + 'gcds-date-input >>> .gcds-date-input__day >>> input', + '03', + ); + + // Type 1991 + await page.type( + 'gcds-date-input >>> .gcds-date-input__year >>> input', + '1991', + ); + + await page.waitForChanges(); + + (await page.find('button')).click(); + + await page.waitForChanges(); + + expect(page.url()).toContain('?date=1991-03-03'); + }); + + it('submit value in compact format', async () => { + const page = await newE2EPage(); + await page.setContent( + `
+ + +
+ `, + ); + + await page.waitForChanges(); + + // Select March + await page.select('gcds-date-input >>> gcds-select >>> select', '03'); + + // Type 1991 + await page.type( + 'gcds-date-input >>> .gcds-date-input__year >>> input', + '1991', + ); + + await page.waitForChanges(); + + (await page.find('button')).click(); + + await page.waitForChanges(); + + expect(page.url()).toContain('?date=1991-03'); + }); +}); + +it('Validation - Missing all fileds', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.all); +}); + +it('Validation - Missing day', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.select('gcds-date-input >>> gcds-select >>> select', '03'); + await page.type( + 'gcds-date-input >>> .gcds-date-input__year >>> input', + '1991', + ); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.missingday); +}); + +it('Validation - Missing year', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.select('gcds-date-input >>> gcds-select >>> select', '03'); + await page.type('gcds-date-input >>> .gcds-date-input__day >>> input', '03'); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.missingyear); +}); + +it('Validation - Missing month', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.type( + 'gcds-date-input >>> .gcds-date-input__year >>> input', + '1991', + ); + await page.type('gcds-date-input >>> .gcds-date-input__day >>> input', '03'); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.missingmonth); +}); + +it('Validation - Missing month and day', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.type( + 'gcds-date-input >>> .gcds-date-input__year >>> input', + '1991', + ); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.missingmonthday); +}); + +it('Validation - Missing day and year', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.select('gcds-date-input >>> gcds-select >>> select', '03'); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.missingdayyear); +}); + +it('Validation - Missing month and year', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.type('gcds-date-input >>> .gcds-date-input__day >>> input', '03'); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.missingmonthyear); +}); + +it('Validation - Year length', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.select('gcds-date-input >>> gcds-select >>> select', '03'); + await page.type('gcds-date-input >>> .gcds-date-input__day >>> input', '03'); + await page.type( + 'gcds-date-input >>> .gcds-date-input__year >>> input', + '19912', + ); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.invalidyearlength); +}); + +it('Validation - Invalid day', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.select('gcds-date-input >>> gcds-select >>> select', '03'); + await page.type('gcds-date-input >>> .gcds-date-input__day >>> input', '34'); + await page.type( + 'gcds-date-input >>> .gcds-date-input__year >>> input', + '1991', + ); + + const dateInput = await page.find('gcds-date-input'); + + dateInput.callMethod('validate'); + await page.waitForChanges(); + + expect( + (await page.find('gcds-date-input >>> gcds-fieldset')).getAttribute( + 'error-message', + ), + ).toBe(dateInputErrorMessage.en.invalidday); +}); + +/** + * Accessibility tests + * Axe-core rules: https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md#wcag-21-level-a--aa-rules + */ + +describe('gcds-date-input a11y tests', () => { + it('Colour contrast', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + const colorContrastTest = new AxePuppeteer(page) + .withRules('color-contrast') + .analyze(); + const results = await colorContrastTest; + + expect(results.violations.length).toBe(0); + }); + + it('Proper labels', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + const labelTest = new AxePuppeteer(page) + .withRules('label') + .analyze(); + const results = await labelTest; + + expect(results.violations.length).toBe(0); + }); + + it('Keyboard focus', async () => { + const page = await newE2EPage(); + await page.setContent( + '', + ); + + await page.keyboard.press('Tab'); + + expect( + await page.evaluate( + () => + window.document.activeElement.shadowRoot.activeElement.nodeName, + ), + ).toEqual('GCDS-SELECT'); + + await page.keyboard.press('Tab'); + + expect( + await page.evaluate( + () => + window.document.activeElement.shadowRoot.activeElement.nodeName, + ), + ).toEqual('GCDS-INPUT'); + + await page.keyboard.press('Tab'); + + expect( + await page.evaluate( + () => + window.document.activeElement.shadowRoot.activeElement.nodeName, + ), + ).toEqual('GCDS-INPUT'); + }); +}); \ No newline at end of file diff --git a/packages/web/src/components/gcds-date-input/test/gcds-date-input.spec.tsx b/packages/web/src/components/gcds-date-input/test/gcds-date-input.spec.tsx new file mode 100644 index 000000000..55df56c4f --- /dev/null +++ b/packages/web/src/components/gcds-date-input/test/gcds-date-input.spec.tsx @@ -0,0 +1,592 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { GcdsDateInput } from '../gcds-date-input'; + +describe('gcds-date-input', () => { + it('renders - full', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - full - french', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - compact', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - compact - french', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - required', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - hint', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - full value', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - compact value', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - full - improper value', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('renders - compact - improper value', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + + + + + + + + + + + + + + + + + + + `); + }); + + it('does notrender - missing all required fields', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + `); + }); + + it('does notrender - missing format', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + `); + }); + + it('does notrender - missing name', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + `); + }); + + it('does notrender - missing legend', async () => { + const page = await newSpecPage({ + components: [GcdsDateInput], + html: ``, + }); + expect(page.root).toEqualHtml(` + + + + `); + }); +}); diff --git a/packages/web/src/components/gcds-fieldset/gcds-fieldset.css b/packages/web/src/components/gcds-fieldset/gcds-fieldset.css index 017f93c02..e7a5abe17 100644 --- a/packages/web/src/components/gcds-fieldset/gcds-fieldset.css +++ b/packages/web/src/components/gcds-fieldset/gcds-fieldset.css @@ -9,6 +9,10 @@ padding: 0; } + legend { + padding: 0; + } + slot { display: block; margin: 0; @@ -30,6 +34,12 @@ .legend__required { margin: var(--gcds-fieldset-legend-required-margin) !important; + font: var(--gcds-fieldset-legend-required-font-desktop); + vertical-align: middle; + + @media only screen and (width < 48em) { + font: var(--gcds-fieldset-legend-required-font-mobile); + } } } } diff --git a/packages/web/src/components/gcds-fieldset/gcds-fieldset.tsx b/packages/web/src/components/gcds-fieldset/gcds-fieldset.tsx index b611c5b71..ff307d189 100644 --- a/packages/web/src/components/gcds-fieldset/gcds-fieldset.tsx +++ b/packages/web/src/components/gcds-fieldset/gcds-fieldset.tsx @@ -36,6 +36,8 @@ export class GcdsFieldset { private shadowElement?: HTMLElement; + isDateInput: boolean = false; + _validator: Validator = defaultValidator; /** @@ -251,7 +253,11 @@ export class GcdsFieldset { this.validateValidator(); // Assign required validator if needed - requiredValidator(this.el, 'fieldset'); + if (this.el.getAttribute('data-date')) { + this.isDateInput = true; + } else { + requiredValidator(this.el, 'fieldset'); + } if (this.validator) { this._validator = getValidator(this.validator); diff --git a/packages/web/src/components/gcds-fieldset/readme.md b/packages/web/src/components/gcds-fieldset/readme.md index dd9aeb872..37b8edff0 100644 --- a/packages/web/src/components/gcds-fieldset/readme.md +++ b/packages/web/src/components/gcds-fieldset/readme.md @@ -44,6 +44,10 @@ Type: `Promise` ## Dependencies +### Used by + + - [gcds-date-input](../gcds-date-input) + ### Depends on - [gcds-hint](../gcds-hint) @@ -57,6 +61,7 @@ graph TD; gcds-hint --> gcds-text gcds-error-message --> gcds-text gcds-error-message --> gcds-icon + gcds-date-input --> gcds-fieldset style gcds-fieldset fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/packages/web/src/components/gcds-input/gcds-input.css b/packages/web/src/components/gcds-input/gcds-input.css index 86469af8d..b589e1d81 100644 --- a/packages/web/src/components/gcds-input/gcds-input.css +++ b/packages/web/src/components/gcds-input/gcds-input.css @@ -18,8 +18,8 @@ @layer default { :host .gcds-input-wrapper { - width: 75ch; - max-width: 90%; + max-width: 75ch; + width: 100%; font: var(--gcds-input-font); color: var(--gcds-input-default-text); transition: color ease-in-out 0.15s; diff --git a/packages/web/src/components/gcds-input/gcds-input.tsx b/packages/web/src/components/gcds-input/gcds-input.tsx index bee49d157..7786a93c8 100644 --- a/packages/web/src/components/gcds-input/gcds-input.tsx +++ b/packages/web/src/components/gcds-input/gcds-input.tsx @@ -165,6 +165,22 @@ export class GcdsInput { */ @State() lang: string; + /** + * Watch HTML attributes to inherit changes + */ + @Watch('aria-invalid') + ariaInvalidWatcher() { + this.inheritedAttributes = inheritAttributes(this.el, this.shadowElement, [ + 'placeholder', + ]); + } + @Watch('aria-description') + ariaDescriptiondWatcher() { + this.inheritedAttributes = inheritAttributes(this.el, this.shadowElement, [ + 'placeholder', + ]); + } + /** * Events */ @@ -342,7 +358,7 @@ export class GcdsInput { // Use max-width to keep field responsive const style = { - maxWidth: `${size * 2}ch`, + maxWidth: `calc(${size * 2}ch + (2 * var(--gcds-input-padding)))`, }; const attrsInput = { @@ -401,9 +417,16 @@ export class GcdsInput { onInput={e => this.handleInput(e, this.gcdsInput)} onChange={e => this.handleInput(e, this.gcdsChange)} aria-labelledby={`label-for-${inputId}`} - aria-invalid={errorMessage ? 'true' : 'false'} + aria-invalid={ + inheritedAttributes['aria-invalid'] === 'true' + ? inheritedAttributes['aria-invalid'] + : errorMessage + ? 'true' + : 'false' + } size={size} style={size ? style : null} + part="input" ref={element => (this.shadowElement = element as HTMLElement)} /> diff --git a/packages/web/src/components/gcds-input/readme.md b/packages/web/src/components/gcds-input/readme.md index 33569c618..1505b1126 100644 --- a/packages/web/src/components/gcds-input/readme.md +++ b/packages/web/src/components/gcds-input/readme.md @@ -52,6 +52,10 @@ Type: `Promise` ## Dependencies +### Used by + + - [gcds-date-input](../gcds-date-input) + ### Depends on - [gcds-label](../gcds-label) @@ -67,6 +71,7 @@ graph TD; gcds-hint --> gcds-text gcds-error-message --> gcds-text gcds-error-message --> gcds-icon + gcds-date-input --> gcds-input style gcds-input fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/packages/web/src/components/gcds-input/test/gcds-input.spec.ts b/packages/web/src/components/gcds-input/test/gcds-input.spec.ts index a6e889e6f..a0a0c9946 100644 --- a/packages/web/src/components/gcds-input/test/gcds-input.spec.ts +++ b/packages/web/src/components/gcds-input/test/gcds-input.spec.ts @@ -18,6 +18,7 @@ describe('gcds-input', () => { type="text" id="input-renders" name="input-renders-name" + part="input" aria-labelledby="label-for-input-renders" aria-invalid="false" /> @@ -44,6 +45,7 @@ describe('gcds-input', () => { type="email" id="type-email" name="type-email-name" + part="input" aria-labelledby="label-for-type-email" aria-invalid="false" /> @@ -67,6 +69,7 @@ describe('gcds-input', () => { type="number" id="type-number" name="type-number-name" + part="input" aria-labelledby="label-for-type-number" aria-invalid="false" /> @@ -90,6 +93,7 @@ describe('gcds-input', () => { type="password" id="type-password" name="type-password-name" + part="input" aria-labelledby="label-for-type-password" aria-invalid="false" /> @@ -113,6 +117,7 @@ describe('gcds-input', () => { type="search" id="type-search" name="type-search-name" + part="input" aria-labelledby="label-for-type-search" aria-invalid="false" /> @@ -136,6 +141,7 @@ describe('gcds-input', () => { type="text" id="type-text" name="type-text-name" + part="input" aria-labelledby="label-for-type-text" aria-invalid="false" /> @@ -159,6 +165,7 @@ describe('gcds-input', () => { type="url" id="type-url" name="type-url-name" + part="input" aria-labelledby="label-for-type-url" aria-invalid="false" /> @@ -185,6 +192,7 @@ describe('gcds-input', () => { type="text" id="input-disabled" name="input-disabled-name" + part="input" aria-labelledby="label-for-input-disabled" aria-invalid="false" disabled="" @@ -216,6 +224,7 @@ describe('gcds-input', () => { id="input-with-error" class="gcds-error" name="input-with-error-name" + part="input" aria-labelledby="label-for-input-with-error" aria-describedby="error-message-input-with-error " aria-invalid="true" @@ -243,6 +252,7 @@ describe('gcds-input', () => { type="text" id="input-label-hidden" name="input-label-hidden-name" + part="input" aria-labelledby="label-for-input-label-hidden" aria-invalid="false" /> @@ -270,6 +280,7 @@ describe('gcds-input', () => { type="text" id="input-with-hint" name="input-with-hint-name" + part="input" aria-labelledby="label-for-input-with-hint" aria-describedby="hint-input-with-hint " aria-invalid="false" @@ -297,6 +308,7 @@ describe('gcds-input', () => { type="text" id="input-renders-id" name="input-renders-id-name" + part="input" aria-labelledby="label-for-input-renders-id" aria-invalid="false" /> @@ -323,6 +335,7 @@ describe('gcds-input', () => { type="text" id="input-renders-label" name="input-renders-label-name" + part="input" aria-labelledby="label-for-input-renders-label" aria-invalid="false" /> @@ -349,6 +362,7 @@ describe('gcds-input', () => { type="text" id="input-required" name="input-required-name" + part="input" aria-labelledby="label-for-input-required" aria-invalid="false" required @@ -376,6 +390,7 @@ describe('gcds-input', () => { type="text" id="input-with-value" name="input-with-value-name" + part="input" value="Input value" aria-labelledby="label-for-input-with-value" aria-invalid="false" @@ -403,6 +418,7 @@ describe('gcds-input', () => { type="text" id="input-with-name" name="input-with-name-name" + part="input" aria-labelledby="label-for-input-with-name" aria-invalid="false" /> diff --git a/packages/web/src/components/gcds-select/gcds-select.css b/packages/web/src/components/gcds-select/gcds-select.css index 6880a5aaa..3ab36c96f 100644 --- a/packages/web/src/components/gcds-select/gcds-select.css +++ b/packages/web/src/components/gcds-select/gcds-select.css @@ -22,7 +22,7 @@ @layer default { :host .gcds-select-wrapper { - max-width: 90%; + max-width: 75ch; font: var(--gcds-select-font); color: var(--gcds-select-default-text); transition: color ease-in-out 0.15s; diff --git a/packages/web/src/components/gcds-select/gcds-select.tsx b/packages/web/src/components/gcds-select/gcds-select.tsx index 6c69c49e9..6e5d59d41 100644 --- a/packages/web/src/components/gcds-select/gcds-select.tsx +++ b/packages/web/src/components/gcds-select/gcds-select.tsx @@ -154,6 +154,18 @@ export class GcdsSelect { */ @State() options: Element[]; + /** + * Watch HTML attribute aria-invalid to inherit changes + */ + @Watch('aria-invalid') + ariaInvalidWatcher() { + this.inheritedAttributes = inheritAttributes(this.el, this.shadowElement); + } + @Watch('aria-description') + ariaDescriptiondWatcher() { + this.inheritedAttributes = inheritAttributes(this.el, this.shadowElement); + } + /** * Events */ @@ -392,7 +404,14 @@ export class GcdsSelect { onFocus={() => this.gcdsFocus.emit()} onInput={e => this.handleInput(e, this.gcdsInput)} onChange={e => this.handleInput(e, this.gcdsChange)} - aria-invalid={hasError ? 'true' : 'false'} + aria-invalid={ + inheritedAttributes['aria-invalid'] === 'true' + ? inheritedAttributes['aria-invalid'] + : errorMessage + ? 'true' + : 'false' + } + part="select" ref={element => (this.shadowElement = element as HTMLSelectElement)} > {defaultValue ? ( diff --git a/packages/web/src/components/gcds-select/readme.md b/packages/web/src/components/gcds-select/readme.md index 3f19093e2..668328b2b 100644 --- a/packages/web/src/components/gcds-select/readme.md +++ b/packages/web/src/components/gcds-select/readme.md @@ -49,6 +49,10 @@ Type: `Promise` ## Dependencies +### Used by + + - [gcds-date-input](../gcds-date-input) + ### Depends on - [gcds-label](../gcds-label) @@ -64,6 +68,7 @@ graph TD; gcds-hint --> gcds-text gcds-error-message --> gcds-text gcds-error-message --> gcds-icon + gcds-date-input --> gcds-select style gcds-select fill:#f9f,stroke:#333,stroke-width:4px ``` 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 e48ef9b8e..008f96a78 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 @@ -12,7 +12,7 @@ describe('gcds-select', () => {
-
@@ -33,7 +33,7 @@ describe('gcds-select', () => {
-
@@ -57,7 +57,7 @@ describe('gcds-select', () => { This is an error message. - @@ -79,7 +79,7 @@ describe('gcds-select', () => {
This is a hint. -
@@ -100,7 +100,7 @@ describe('gcds-select', () => {
-
@@ -121,7 +121,7 @@ describe('gcds-select', () => {
-
@@ -142,7 +142,7 @@ describe('gcds-select', () => {
-
@@ -169,7 +169,7 @@ describe('gcds-select', () => {
- @@ -202,7 +202,7 @@ describe('gcds-select', () => {
- diff --git a/packages/web/src/index.html b/packages/web/src/index.html index 9b5709d32..ea3ff477d 100644 --- a/packages/web/src/index.html +++ b/packages/web/src/index.html @@ -269,6 +269,14 @@ required > + + { it('returns Fallback Button Label', () => { @@ -9,3 +9,95 @@ describe('format', () => { expect(format('Vanilla JS button')).toEqual(' Vanilla JS button'); }); }); + +describe('logError', () => { + it('creates error message with required attributes', () => { + const errorSpy = jest.spyOn(console, 'error'); + + logError('gcds-component', ['requiredAttr']); + + expect(errorSpy).toHaveBeenCalledWith( + 'gcds-component: Render error, please check required properties. (requiredAttr) | gcds-component: Erreur de rendu, veuillez vérifier les propriétés requises. (requiredAttr)', + ); + }); + + it('creates error message with excluded optional attributes', () => { + const errorSpy = jest.spyOn(console, 'error'); + + logError('gcds-component', ['requiredAttr', 'optionalAttr'], ['optionalAttr']); + + expect(errorSpy).toHaveBeenCalledWith( + 'gcds-component: Render error, please check required properties. (requiredAttr) | gcds-component: Erreur de rendu, veuillez vérifier les propriétés requises. (requiredAttr)', + ); + }); + + it('creates error message with no attributes', () => { + const errorSpy = jest.spyOn(console, 'error'); + + logError('gcds-component', [], []); + + expect(errorSpy).toHaveBeenCalledWith( + 'gcds-component: Render error, please check required properties. () | gcds-component: Erreur de rendu, veuillez vérifier les propriétés requises. ()', + ); + }); + + it('creates error message with same attributes in required and optional arrays', () => { + const errorSpy = jest.spyOn(console, 'error'); + + logError('gcds-component', ['sameAttr'], ['sameAttr']); + + expect(errorSpy).toHaveBeenCalledWith( + 'gcds-component: Render error, please check required properties. () | gcds-component: Erreur de rendu, veuillez vérifier les propriétés requises. ()', + ); + }); +}); + +describe('isValidDate', () => { + it('returns true - full format', () => { + expect(isValidDate('1991-03-03')).toEqual(true); + }); + + it('returns true - full format - forced format', () => { + expect(isValidDate('1991-03-03', 'full')).toEqual(true); + }); + + it('returns true - full format - leap year', () => { + expect(isValidDate('1992-02-29')).toEqual(true); + }); + + it('returns true - compact format', () => { + expect(isValidDate('1991-03')).toEqual(true); + }); + + it('returns true - compact format - forced format', () => { + expect(isValidDate('1991-03', 'compact')).toEqual(true); + }); + + it('returns false - full format - invalid month', () => { + expect(isValidDate('1991-13-03')).toEqual(false); + }); + + it('returns false - full format - invalid day', () => { + expect(isValidDate('1991-02-29')).toEqual(false); + }); + + it('returns false - compact format - invalid month', () => { + expect(isValidDate('1991-1')).toEqual(false); + }); + + it('returns false - full format - invalid year', () => { + expect(isValidDate('199-02-29')).toEqual(false); + }); + + it('returns false - compact format - invalid year', () => { + expect(isValidDate('199-1')).toEqual(false); + }); + + it('returns false - full format - force compact', () => { + expect(isValidDate('1991-03-03', 'compact')).toEqual(false); + }); + + it('returns false - compact format - force full', () => { + expect(isValidDate('1991-03', 'full')).toEqual(false); + }); +}); \ No newline at end of file diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts index d72482c01..bbc30d14f 100644 --- a/packages/web/src/utils/utils.ts +++ b/packages/web/src/utils/utils.ts @@ -9,14 +9,15 @@ export const inheritAttributes = ( shadowElement: HTMLElement, attributes: string[] = [], ) => { - const attributeObject = {}; + let attributeObject = {}; + let attributesToRemove = []; - // Check for any aria or data attributes + // Check for any aria attributes for (let i = 0; i < el.attributes.length; i++) { const attr = el.attributes[i]; if (attr.name.includes('aria-')) { attributeObject[attr.name] = attr.value; - el.removeAttribute(attr.name); + attributesToRemove.push(attr.name); } } @@ -112,3 +113,81 @@ export const emitEvent = ( return true; }; + +/* Log validation error for required properties in components + * @param name - name of the component i.e. + * @param errorArr - array of attributes with errors + * @param optionalAttrsArrToRemove - array of optional attributes with errors to be removed from this error message + */ +export const logError = ( + name: string, + errorArr: string[], + optionalAttrsArrToRemove?: string[], +) => { + let engMsg = 'Render error, please check required properties.'; + let frMsg = 'Erreur de rendu, veuillez vérifier les propriétés requises.'; + let errors = [...errorArr]; + + // remove any potential optional attributes from errors array + if (optionalAttrsArrToRemove && optionalAttrsArrToRemove.length > 0) { + for (const optionalAttr of optionalAttrsArrToRemove) { + if (errors.includes(optionalAttr)) { + errors.splice(errors.indexOf(optionalAttr), 1); + } + } + } + + console.error( + `${name}: ${engMsg} (${errors}) | ${name}: ${frMsg} (${errors})`, + ); +}; + +/* Check for valid date + * @param dateString - the date to check + */ +export const isValidDate = (dateString: string, forceFormat?: 'full' | 'compact') => { + // Define regex pattern to match YYYY-MM-DD format + let fullregex = /^\d{4}-\d{2}-\d{2}$/; + let compactregex = /^\d{4}-\d{2}$/; + let format = ''; + + // Check if the format matches the regex + if (fullregex.test(dateString)) { + format = 'full'; + } else if (compactregex.test(dateString)) { + format = 'compact'; + } else { + return false; + } + + if (forceFormat && format != forceFormat) { + return false; + } + + // Parse the date string into a Date object + const formattedDate = `${dateString}${format === 'compact' ? '-15' : ''}`; + + // Check if the date is valid + const [year, month, day] = formattedDate.split('-').map(Number); + + const thirtyOneDays = [1, 3, 5, 7, 8, 10, 12]; + const thirtyDays = [4, 6, 9, 11]; + + if (month < 1 || month > 12) { + return false; + } else if (thirtyDays.includes(month) && (day < 1 || day > 30)) { + return false; + } else if (thirtyOneDays.includes(month) && (day < 1 || day > 31)) { + return false; + } else if (!isLeapYear(year) && month === 2 && (day < 1 || day > 28)) { + return false; + } else if (isLeapYear(year) && month === 2 && (day < 1 || day > 29)) { + return false; + } + + return true; +}; + +function isLeapYear(y: number) { + return !(y & 3 || (!(y % 25) && y & 15)); +} diff --git a/packages/web/src/validators/input-validators/input-validators.ts b/packages/web/src/validators/input-validators/input-validators.ts index 1910fec08..0587e2810 100644 --- a/packages/web/src/validators/input-validators/input-validators.ts +++ b/packages/web/src/validators/input-validators/input-validators.ts @@ -1,4 +1,5 @@ import { Validator } from '../validator'; +import { isValidDate } from '../../utils/utils'; const emailPattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; @@ -37,3 +38,146 @@ export const requiredSelectField: Validator = { fr: 'Choisissez une option pour continuer.', }, }; + +/* + * Date input validators + */ +export const dateInputErrorMessage = { + en: { + all: 'Enter the date.', + missingmonth: 'Select the month.', + missingyear: 'Enter the year.', + missingday: 'Enter the day.', + missingmonthday: 'Select the month and enter the day.', + missingmonthyear: 'Select the month and enter the year.', + missingdayyear: 'Enter the day and year.', + invalidyearlength: 'Year must be 4 digits.', + invalidyear: 'Enter a valid year.', + invalidday: 'Enter a valid day.', + }, + fr: { + all: 'Saisissez la date.', + missingmonth: 'Sélectionnez un mois.', + missingyear: "Saisissez l'année.", + missingday: 'Saisissez le jour.', + missingmonthday: 'Saisissez le jour et sélectionnez un mois.', + missingmonthyear: "Sélectionnez un mois et saisissez l'année.", + missingdayyear: "Saisissez le jour et l'année.", + invalidyearlength: "L'année doit inclure 4 chiffres.", + invalidyear: 'Entrez une année valide.', + invalidday: 'Saisissez un jour valide.', + }, +}; + +export const requiredDateInput: Validator = { + validate: (date: string) => { + if (isValidDate(date)) { + return { valid: true }; + } + + let splitDate = date.split('-'); + let dateObject = { + day: splitDate[2], + month: splitDate[1], + year: splitDate[0], + }; + + let format = splitDate.length === 3 ? 'full' : 'compact'; + + const error = getDateInputError(dateObject, format); + + return error; + }, + errorMessage: dateInputErrorMessage, +}; + +const getDateInputError = (dateValues, format) => { + const { day, month, year } = dateValues; + + let errorResponse = { + valid: false, + errors: { + day: false, + month: false, + year: false, + }, + reason: { + en: '', + fr: '', + }, + }; + + // No values set + if (!day && !month && !year) { + errorResponse.errors.day = true; + errorResponse.errors.month = true; + errorResponse.errors.year = true; + errorResponse.reason.en = dateInputErrorMessage.en.all; + errorResponse.reason.fr = dateInputErrorMessage.fr.all; + + // No day set + } else if (!day && month && year) { + errorResponse.errors.day = true; + errorResponse.reason.en = dateInputErrorMessage.en.missingday; + errorResponse.reason.fr = dateInputErrorMessage.fr.missingday; + + // No month set + } else if ( + (day && !month && year) || + (!day && !month && year && format === 'compact') + ) { + errorResponse.errors.month = true; + errorResponse.reason.en = dateInputErrorMessage.en.missingmonth; + errorResponse.reason.fr = dateInputErrorMessage.fr.missingmonth; + + // No year set + } else if ( + (day && month && !year) || + (!day && month && !year && format === 'compact') + ) { + errorResponse.errors.year = true; + errorResponse.reason.en = dateInputErrorMessage.en.missingyear; + errorResponse.reason.fr = dateInputErrorMessage.fr.missingyear; + + // No day and month set + } else if (!day && !month && year) { + errorResponse.errors.day = true; + errorResponse.errors.month = true; + errorResponse.reason.en = dateInputErrorMessage.en.missingmonthday; + errorResponse.reason.fr = dateInputErrorMessage.fr.missingmonthday; + + // No day and year set + } else if (!day && month && !year) { + errorResponse.errors.day = true; + errorResponse.errors.year = true; + errorResponse.reason.en = dateInputErrorMessage.en.missingdayyear; + errorResponse.reason.fr = dateInputErrorMessage.fr.missingdayyear; + + // No month and year set + } else if (day && !month && !year) { + errorResponse.errors.year = true; + errorResponse.errors.month = true; + errorResponse.reason.en = dateInputErrorMessage.en.missingmonthyear; + errorResponse.reason.fr = dateInputErrorMessage.fr.missingmonthyear; + + // Year is formatted incorrectly + } else if (year.length != 4) { + errorResponse.errors.year = true; + errorResponse.reason.en = dateInputErrorMessage.en.invalidyearlength; + errorResponse.reason.fr = dateInputErrorMessage.fr.invalidyearlength; + + // Year format + } else if (year < 0 || year > 9999) { + errorResponse.errors.year = true; + errorResponse.reason.en = dateInputErrorMessage.en.invalidyear; + errorResponse.reason.fr = dateInputErrorMessage.fr.invalidyear; + + // Invalid day + } else { + errorResponse.errors.day = true; + errorResponse.reason.en = dateInputErrorMessage.en.invalidday; + errorResponse.reason.fr = dateInputErrorMessage.fr.invalidday; + } + + return errorResponse; +}; diff --git a/packages/web/src/validators/input-validators/test/input-validators.spec.tsx b/packages/web/src/validators/input-validators/test/input-validators.spec.tsx index 065e7244e..3b683db19 100644 --- a/packages/web/src/validators/input-validators/test/input-validators.spec.tsx +++ b/packages/web/src/validators/input-validators/test/input-validators.spec.tsx @@ -1,5 +1,10 @@ -import { requiredField, requiredFileInput } from '../input-validators'; +import { + requiredField, + requiredFileInput, + requiredDateInput, +} from '../input-validators'; import { Blob } from 'buffer'; +import { dateInputErrorMessage } from '../input-validators'; interface MockFile { name: string; @@ -66,3 +71,164 @@ describe('Required file input validator', () => { }), ); }); + +describe('Required date input validator', () => { + const results: Array<{ value: string; res: object }> = [ + { value: '1991-03-04', res: { valid: true } }, + { value: '1992-02-29', res: { valid: true } }, + { value: '1991-03', res: { valid: true } }, + { + value: '--', + res: { + valid: false, + errors: { day: true, month: true, year: true }, + reason: { en: dateInputErrorMessage.en.all, fr: dateInputErrorMessage.fr.all }, + }, + }, + { + value: '-', + res: { + valid: false, + errors: { day: true, month: true, year: true }, + reason: { en: dateInputErrorMessage.en.all, fr: dateInputErrorMessage.fr.all }, + }, + }, + { + value: '1991-03-', + res: { + valid: false, + errors: { day: true, month: false, year: false }, + reason: { + en: dateInputErrorMessage.en.missingday, + fr: dateInputErrorMessage.fr.missingday, + }, + }, + }, + { + value: '1991--04', + res: { + valid: false, + errors: { day: false, month: true, year: false }, + reason: { + en: dateInputErrorMessage.en.missingmonth, + fr: dateInputErrorMessage.fr.missingmonth, + }, + }, + }, + { + value: '1991-', + res: { + valid: false, + errors: { day: false, month: true, year: false }, + reason: { + en: dateInputErrorMessage.en.missingmonth, + fr: dateInputErrorMessage.fr.missingmonth, + }, + }, + }, + { + value: '-03-04', + res: { + valid: false, + errors: { day: false, month: false, year: true }, + reason: { + en: dateInputErrorMessage.en.missingyear, + fr: dateInputErrorMessage.fr.missingyear, + }, + }, + }, + { + value: '-03', + res: { + valid: false, + errors: { day: false, month: false, year: true }, + reason: { + en: dateInputErrorMessage.en.missingyear, + fr: dateInputErrorMessage.fr.missingyear, + }, + }, + }, + { + value: '1991--', + res: { + valid: false, + errors: { day: true, month: true, year: false }, + reason: { + en: dateInputErrorMessage.en.missingmonthday, + fr: dateInputErrorMessage.fr.missingmonthday, + }, + }, + }, + { + value: '-03-', + res: { + valid: false, + errors: { day: true, month: false, year: true }, + reason: { + en: dateInputErrorMessage.en.missingdayyear, + fr: dateInputErrorMessage.fr.missingdayyear, + }, + }, + }, + { + value: '--04', + res: { + valid: false, + errors: { day: false, month: true, year: true }, + reason: { + en: dateInputErrorMessage.en.missingmonthyear, + fr: dateInputErrorMessage.fr.missingmonthyear, + }, + }, + }, + { + value: '19991-03-04', + res: { + valid: false, + errors: { day: false, month: false, year: true }, + reason: { + en: dateInputErrorMessage.en.invalidyearlength, + fr: dateInputErrorMessage.fr.invalidyearlength, + }, + }, + }, + { + value: '-991-03-04', + res: { + valid: false, + errors: { day: false, month: false, year: true }, + reason: { + en: dateInputErrorMessage.en.missingyear, + fr: dateInputErrorMessage.fr.missingyear, + }, + }, + }, + { + value: '1991-35-04', + res: { + valid: false, + errors: { day: true, month: false, year: false }, + reason: { + en: dateInputErrorMessage.en.invalidday, + fr: dateInputErrorMessage.fr.invalidday, + }, + }, + }, + { + value: '1991-03-34', + res: { + valid: false, + errors: { day: true, month: false, year: false }, + reason: { + en: dateInputErrorMessage.en.invalidday, + fr: dateInputErrorMessage.fr.invalidday, + }, + }, + }, + ]; + results.forEach(i => + it(`Should return ${i.res['valid']} for ${i.value}`, () => { + expect(requiredDateInput.validate(i.value)).toEqual(i.res); + }), + ); +}); diff --git a/packages/web/src/validators/validator.factory.ts b/packages/web/src/validators/validator.factory.ts index 97996f20f..f7b014a09 100644 --- a/packages/web/src/validators/validator.factory.ts +++ b/packages/web/src/validators/validator.factory.ts @@ -9,6 +9,7 @@ import { requiredEmailField, requiredFileInput, requiredSelectField, + requiredDateInput, } from './input-validators/input-validators'; import { requiredCheck } from './checkbox-validators/checkbox-validators'; import { requiredFieldset } from './fieldset-validators/fieldset-validators'; @@ -20,6 +21,7 @@ export enum ValidatorsName { requiredFieldset = 'requiredFieldset', requiredFileInput = 'requiredFileInput', requiredSelectField = 'requiredSelectField', + requiredDateInput = 'requiredDateInput', } export function getValidator( @@ -52,6 +54,8 @@ export function validatorFactory(name: string, options: any): Validator { return requiredCheck; case ValidatorsName.requiredFieldset: return requiredFieldset; + case ValidatorsName.requiredDateInput: + return requiredDateInput; case ValidatorsName.requiredFileInput: return requiredFileInput; default: diff --git a/packages/web/src/validators/validator.ts b/packages/web/src/validators/validator.ts index e15399333..bd6154ed0 100644 --- a/packages/web/src/validators/validator.ts +++ b/packages/web/src/validators/validator.ts @@ -1,5 +1,5 @@ export interface Validator { - validate: (x: A) => boolean; + validate: (x: A) => any; errorMessage?: object; } @@ -97,6 +97,13 @@ export function requiredValidator(element, type, subtype?) { element.validator = ['requiredFieldset']; } break; + case 'date-input': + if (element.validator) { + element.validator.unshift('requiredDateInput'); + } else { + element.validator = ['requiredDateInput']; + } + break; } } }