diff --git a/documents/src/pages/elements/time-picker.md b/documents/src/pages/elements/time-picker.md index 526affb7e3..eef7fbe696 100644 --- a/documents/src/pages/elements/time-picker.md +++ b/documents/src/pages/elements/time-picker.md @@ -112,6 +112,81 @@ utcTimePicker.hours = date.getUTCHours(); utcTimePicker.minutes = date.getUTCMinutes(); ``` +## Input validation +`ef-time-picker` has validation logic similar to a [native input](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time). When a user types a partial value into the control, error style will be shown to notify the user. + +You can call `reportValidity()` to trigger the validation anytime, and it will set error style if input is partial. In case that the input is initially or programmatically set to an invalid value, you must call `reportValidity()` to show the error style. Make sure that the element has been defined before calling the method. + +Whenever input is invalid, the `error` attribute will be added to the element. You can use the `error` property to check whether input is currently in the error state or not. +If the error state is changed (not programmatically), an `error-changed` event will be dispatched along with the current error state. + +:: +```javascript +::import-elements:: +const errorStatus = document.querySelector('p'); +const el = document.querySelector('ef-time-picker'); + +el.addEventListener('error-changed', (event) => { + errorStatus.textContent = event.detail.value ? 'error due to partial input' : ''; +}); +``` +```css +p { + color: red; +} +``` +```html +
+ +

+
+``` +:: + +### Custom validation +For advance use cases, default validation and error state of the field can be overridden. +To do this, make sure that `custom-validation` is set, +then validate with your customised validation logic and update `error` property accordingly. + +:: +```javascript +::import-elements:: +const errorNotice = document.getElementById('error-notice'); +const el = document.querySelector('ef-time-picker'); + +el.addEventListener('value-changed', (event) => { + const targetEl = event.target; + if ((targetEl.hours < 8) || (targetEl.hours >= 17 && targetEl.minutes > 0)) { + errorNotice.textContent = 'Not in the working hour'; + targetEl.error = true; + } else { + errorNotice.textContent = ''; + targetEl.error = false; + } +}); + +el.addEventListener('blur', (event) => { + const targetEl = event.target; + if (!targetEl.hours || !targetEl.minutes) { + errorNotice.textContent = 'Please choose time'; + targetEl.error = true; + } +}); +``` +```css +#error-notice { + color: red; +} +``` +```html +
+

Please choose a time to receive service (Service hours 8:00-17:00)

+ +

+
+``` +:: + ## Combining time and date Typically, the time value must be combined with a date object in order to use an API. To do this, use methods on the native `Date` object. diff --git a/packages/elemental-theme/src/custom-elements/ef-time-picker.less b/packages/elemental-theme/src/custom-elements/ef-time-picker.less index b9d8b3ed36..d6bc6c1f8f 100644 --- a/packages/elemental-theme/src/custom-elements/ef-time-picker.less +++ b/packages/elemental-theme/src/custom-elements/ef-time-picker.less @@ -65,6 +65,17 @@ &:extend(:host[focused]); // Extend ef-number-field[focused] } +:host[error] { + &:extend(:host[error]); // Extend ef-number-field[error] + [part='input'], + [part='toggle'] { + &:focus::after, + &[focused]::after { + border-bottom-color: @scheme-color-error; + } + } +} + :host[disabled] { &:extend(:host[disabled]); // Extend ef-number-field[disabled] } diff --git a/packages/elements/src/datetime-picker/__snapshots__/DOMStructure.md b/packages/elements/src/datetime-picker/__snapshots__/DOMStructure.md index cbec3342a5..1b5e23b1ce 100644 --- a/packages/elements/src/datetime-picker/__snapshots__/DOMStructure.md +++ b/packages/elements/src/datetime-picker/__snapshots__/DOMStructure.md @@ -278,6 +278,7 @@
' + ); + + el.timepickerEl.hours = null; + el.opened = false; + await elementUpdated(el); + + el.opened = true; + await elementUpdated(el); + + expect(el.timepickerEl.value).to.equal('11:00'); + }); + it('It should fall back timepicker values to valid when popup is opened in range mode', async function () { + const el = await fixture( + '' + ); + + el.timepickerFromEl.hours = null; + el.timepickerToEl.minutes = null; + el.opened = false; + await elementUpdated(el); + + el.opened = true; + await elementUpdated(el); + + expect(el.timepickerFromEl.value).to.equal('11:00'); + expect(el.timepickerToEl.value).to.equal('15:00'); + }); // TODO: add input validation test cases when the value update is originated from typing input }); }); diff --git a/packages/elements/src/datetime-picker/index.ts b/packages/elements/src/datetime-picker/index.ts index 90477106d7..e1db422fff 100644 --- a/packages/elements/src/datetime-picker/index.ts +++ b/packages/elements/src/datetime-picker/index.ts @@ -489,6 +489,7 @@ export class DatetimePicker extends FormFieldElement implements MultiValue { protected override update(changedProperties: PropertyValues): void { if (changedProperties.has('opened') && this.opened) { this.lazyRendered = true; + this.syncTimePickerInput(); } // make sure to close popup for disabled if (this.opened && !this.canOpenPopup) { @@ -538,6 +539,34 @@ export class DatetimePicker extends FormFieldElement implements MultiValue { void super.performUpdate(); } + /** + * if the time-picker input(s) is invalid + * it will sync time-picker value to previous valid value that store in datetime-picker + * @returns {void} + */ + private syncTimePickerInput(): void { + if (!this.timepicker || !this.opened) { + return; + } + + const validateAndFallback = (element: TimePicker | null | undefined, value: string) => { + if (!element) { + return; + } + + if (!element.checkValidity() || (!element.value && value)) { + element.value = value; + } + }; + + if (this.range) { + validateAndFallback(this.timepickerFromEl, this.timepickerValues[0]); + validateAndFallback(this.timepickerToEl, this.timepickerValues[1]); + } else { + validateAndFallback(this.timepickerEl, this.timepickerValues[0]); + } + } + /** * Overwrite validation method for value * @@ -1220,6 +1249,7 @@ export class DatetimePicker extends FormFieldElement implements MultiValue { return html` - + @@ -56,22 +56,60 @@ + +

Please choose a time to receive service (Service hours 8:00-17:00)

+ +

+ +
+

Value-changed event and error-change event

value-changed:
+
+ error-changed: + +
diff --git a/packages/elements/src/time-picker/__test__/time-picker.test.js b/packages/elements/src/time-picker/__test__/time-picker.test.js index 8115cb1ac8..29bd30a7b9 100644 --- a/packages/elements/src/time-picker/__test__/time-picker.test.js +++ b/packages/elements/src/time-picker/__test__/time-picker.test.js @@ -267,6 +267,118 @@ describe('time-picker/TimePicker', function () { await elementUpdated(el); expect(el.seconds).to.equal(null); }); + it('should set error state to false when reportValidity is called without value', async function () { + const el = await fixture(''); + const validity = el.reportValidity(); + expect(el.error).to.be.equal(false); + expect(validity).to.be.equal(true); + }); + it('should set error state to true when reportValidity is called with partial value', async function () { + const el = await fixture(''); + const validity = el.reportValidity(); + expect(el.error).to.be.equal(true); + expect(validity).to.be.equal(false); + }); + it('should set error state to false when reportValidity is called with valid values', async function () { + const el = await fixture(''); + const validity = el.reportValidity(); + expect(el.error).to.be.equal(false); + expect(validity).to.be.equal(true); + }); + it('should add error state when value is partial by a mock user interaction', async function () { + const el = await fixture(timePickerDefaults); + el.hoursInput.value = '12'; + setTimeout(() => el.hoursInput.dispatchEvent(new Event('input'))); + await oneEvent(el.hoursInput, 'input'); + expect(el.error).to.be.equal(true); + }); + it('should remove error state when value is not partial by a mock user interaction', async function () { + const el = await fixture(timePickerDefaults); + el.hoursInput.value = '12'; + setTimeout(() => el.hoursInput.dispatchEvent(new Event('input'))); + await oneEvent(el.hoursInput, 'input'); + expect(el.error).to.be.equal(true); + + el.minutesInput.value = '00'; + setTimeout(() => el.minutesInput.dispatchEvent(new Event('input'))); + await oneEvent(el.minutesInput, 'input'); + expect(el.error).to.be.equal(false); + }); + it('should add error state when value is partial with show seconds by a mock user interaction', async function () { + const el = await fixture(''); + el.hoursInput.value = '12'; + el.minutesInput.value = '00'; + setTimeout(() => { + el.hoursInput.dispatchEvent(new Event('input')); + el.minutesInput.dispatchEvent(new Event('input')); + }); + await Promise.all([oneEvent(el.minutesInput, 'input'), oneEvent(el.hoursInput, 'input')]); + expect(el.error).to.be.equal(true); + }); + it('should remove error state when value is not partial with show seconds by a mock user interaction', async function () { + const el = await fixture(''); + el.hoursInput.value = '12'; + el.minutesInput.value = '00'; + setTimeout(() => { + el.hoursInput.dispatchEvent(new Event('input')); + el.minutesInput.dispatchEvent(new Event('input')); + }); + await Promise.all([oneEvent(el.minutesInput, 'input'), oneEvent(el.hoursInput, 'input')]); + expect(el.error).to.be.equal(true); + + el.secondsInput.value = '00'; + setTimeout(() => el.secondsInput.dispatchEvent(new Event('input'))); + await oneEvent(el.secondsInput, 'input'); + expect(el.error).to.be.equal(false); + }); + it('should not add error state when remove all segments by a mock user interaction', async function () { + const el = await fixture(''); + el.hoursInput.value = ''; + setTimeout(() => el.hoursInput.dispatchEvent(new Event('input'))); + await oneEvent(el.hoursInput, 'input'); + expect(el.error).to.be.equal(true); + + el.minutesInput.value = ''; + setTimeout(() => el.minutesInput.dispatchEvent(new Event('input'))); + await oneEvent(el.minutesInput, 'input'); + expect(el.error).to.be.equal(true); + + el.secondsInput.value = ''; + setTimeout(() => el.secondsInput.dispatchEvent(new Event('input'))); + await oneEvent(el.secondsInput, 'input'); + expect(el.error).to.be.equal(false); + }); + it('should add error state when type invalid value by a mock user interaction', async function () { + const el = await fixture(''); + el.secondsInput.value = '88'; + setTimeout(() => el.secondsInput.dispatchEvent(new Event('input'))); + await oneEvent(el.secondsInput, 'input'); + expect(el.error).to.be.equal(true); + }); + it('Should add error state if tap on toggle when there is no value', async function () { + const el = await fixture(timePickerAMPM); + el.value = ''; + await elementUpdated(el); + const toggleEl = el.renderRoot.querySelector('#toggle'); + toggleEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await elementUpdated(el); + expect(el.error).to.equal(true); + }); + it('Should remove error state if tap on toggle while there is only hour segment to fill in', async function () { + const el = await fixture(''); + + el.minutesInput.value = '00'; + setTimeout(() => { + el.minutesInput.dispatchEvent(new Event('input')); + }); + await oneEvent(el.minutesInput, 'input'); + expect(el.error).to.be.equal(true); + + const toggleEl = el.renderRoot.querySelector('#toggle'); + toggleEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await elementUpdated(el); + expect(el.error).to.equal(false); + }); }); describe('Modes', function () { @@ -377,6 +489,22 @@ describe('time-picker/TimePicker', function () { expect(el.value).to.equal('10:20', 'should be 10:20'); }); + it('Should able to toggle mode between 12hr and 24hr by API toggle method', async function () { + const el = await fixture(timePickerAMPM); + + el.toggle(); + await elementUpdated(el); + expect(el.hours).to.equal(1); + expect(el.formattedHours).to.equal('01', 'should be 01'); + expect(el.value).to.equal('01:30', 'should be 01:30'); + + el.toggle(); + await elementUpdated(el); + expect(el.hours).to.equal(13); + expect(el.formattedHours).to.equal('01', 'should be 01'); + expect(el.value).to.equal('13:30', 'should be 13:30'); + }); + it('Should able to toggle am/pm', async function () { const el = await fixture(timePickerAMPM); const toggleEl = el.renderRoot.querySelector('#toggle'); diff --git a/packages/elements/src/time-picker/index.ts b/packages/elements/src/time-picker/index.ts index f4affc18ca..f82b84e6d5 100644 --- a/packages/elements/src/time-picker/index.ts +++ b/packages/elements/src/time-picker/index.ts @@ -1,9 +1,6 @@ -import type { FocusedChangedEvent, ValueChangedEvent } from '../events'; -import type { NumberField } from '../number-field'; - import { CSSResultGroup, - ControlElement, + FormFieldElement, PropertyValues, TemplateResult, css, @@ -37,6 +34,8 @@ import { toTimeSegment } from '@refinitiv-ui/utils/date.js'; +import type { FocusedChangedEvent, ValueChangedEvent } from '../events'; +import type { NumberField } from '../number-field'; import '../number-field/index.js'; import { VERSION } from '../version.js'; @@ -77,15 +76,19 @@ const Placeholder = { /** * Control the time input * @event value-changed - Fired when the user commits a value change. The event is not triggered if `value` property is changed programmatically. + * @event error-changed - Fired when user inputs invalid value. The event is not triggered if `error` property is changed programmatically. * * @attr {boolean} readonly - Set readonly state * @prop {boolean} [readonly=false] - Set readonly state * * @attr {boolean} disabled - Set disabled state * @prop {boolean} [disabled=false] - Set disabled state + * + * @attr {boolean} error - Set error state + * @prop {boolean} [error=false] - Set error state */ @customElement('ef-time-picker') -export class TimePicker extends ControlElement { +export class TimePicker extends FormFieldElement { /** * Element version number * @returns version number @@ -122,6 +125,12 @@ export class TimePicker extends ControlElement { */ private valueWithSeconds = false; + /** + * Disable automatic build-in validation checking for partial input of hour, minute & second (if applicable) segments + */ + @property({ type: Boolean, attribute: 'custom-validation' }) + public customValidation = false; + /** * Hours time segment in 24hr format * @param hours hours value @@ -584,6 +593,10 @@ export class TimePicker extends ControlElement { } this.updateSegmentValue(segment); + + if (!this.customValidation) { + this.reportValidity(); + } } /** @@ -606,6 +619,56 @@ export class TimePicker extends ControlElement { } } + /** + * Returns `true` if all input segments contain valid data or empty. Otherwise, returns false. + * @returns true if input is valid + */ + public checkValidity(): boolean { + const hours = this.hoursInput?.value; + const minutes = this.minutesInput?.value; + const seconds = this.secondsInput?.value; + // If no values are provided in all segment, there is no error + if (!hours && !minutes && !seconds) { + return true; + } + + const checkValues = (value: string | undefined, maxUnit: number) => { + if (!value) { + return false; + } + const _value = Number(value); + return TimePicker.validUnit(_value, MIN_UNIT, maxUnit, null) === _value; + }; + + const validHour = checkValues(hours, MAX_HOURS); + const validMinute = checkValues(minutes, MAX_MINUTES); + const validSecond = checkValues(seconds, MAX_SECONDS); + // Check second only when it's enabled + return validHour && validMinute && (!this.isShowSeconds || validSecond); + } + + /** + * Validate input. Mark as error if input is invalid + * @returns false if there is an error + */ + public reportValidity(): boolean { + const hasError = !this.checkValidity(); + this.notifyErrorChange(hasError); + return !hasError; + } + + /** + * Handle validation on input segments + * @returns {void} + */ + private onInputValidation(): void { + if (this.customValidation) { + return; + } + + this.reportValidity(); + } + /** * Updates a time segment to the provided value * @param segment Segment id @@ -688,7 +751,7 @@ export class TimePicker extends ControlElement { */ private handleEnterKey(event: KeyboardEvent): void { if (event.target === this.toggleEl) { - this.toggle(); + void this.onToggle(); event.preventDefault(); } } @@ -720,7 +783,7 @@ export class TimePicker extends ControlElement { */ private toggleOrModify(amount: number, target: HTMLElement): void { if (target === this.toggleEl) { - this.toggle(); + void this.onToggle(); } else if (target === this.hoursInput) { this.changeValueBy(amount, Segment.HOURS); } else if (target === this.minutesInput) { @@ -833,10 +896,31 @@ export class TimePicker extends ControlElement { * @returns {void} */ public toggle(): void { + void this.onToggle(false); + } + + /** + * Handle tap toggle between AP and PM state + * @param userInteraction indicates whether the toggle is triggered by user interaction or not + * @returns {void} + */ + private async onToggle(userInteraction = true): Promise { if (this.amPm) { const hours = this.hours === null ? new Date().getHours() : (this.hours + HOURS_IN_DAY / 2) % HOURS_IN_DAY; + + if (!userInteraction) { + this.hours = hours; // set segment without notifying value change + return; + } + this.setSegmentAndNotify(Segment.HOURS, hours); + + await this.updateComplete; + // The segment needs to be validated when its value has been changed + if (!this.customValidation) { + this.reportValidity(); + } } } @@ -893,6 +977,7 @@ export class TimePicker extends ControlElement { ?readonly="${this.readonly}" @value-changed="${this.onInputValueChanged}" @focused-changed=${this.onInputFocusedChanged} + @input=${this.onInputValidation} >`; } @@ -916,6 +1001,7 @@ export class TimePicker extends ControlElement { transparent @value-changed="${this.onInputValueChanged}" @focused-changed=${this.onInputFocusedChanged} + @input=${this.onInputValidation} >`; } @@ -939,6 +1025,7 @@ export class TimePicker extends ControlElement { transparent @value-changed="${this.onInputValueChanged}" @focused-changed=${this.onInputFocusedChanged} + @input=${this.onInputValidation} >`; } @@ -957,7 +1044,7 @@ export class TimePicker extends ControlElement { aria-activedescendant="${hasHours ? (this.isAM() ? 'toggle-am' : 'toggle-pm') : nothing}" id="toggle" part="toggle" - @tap=${this.toggle} + @tap=${this.onToggle} tabindex="0" >