diff --git a/src/components/calcite-input-time-picker/calcite-input-time-picker.e2e.ts b/src/components/calcite-input-time-picker/calcite-input-time-picker.e2e.ts new file mode 100644 index 00000000000..4fe6e59dbc6 --- /dev/null +++ b/src/components/calcite-input-time-picker/calcite-input-time-picker.e2e.ts @@ -0,0 +1,318 @@ +import { newE2EPage } from "@stencil/core/testing"; +import { accessible, defaults, focusable, reflects, renders } from "../../tests/commonTests"; +import { formatTimePart } from "../../utils/time"; + +describe("calcite-input-time-picker", () => { + it("renders", async () => renders("calcite-input-time-picker")); + + it("is accessible", async () => + accessible(` + + `)); + + it("has defaults", async () => + defaults("calcite-input-time-picker", [ + { propertyName: "scale", defaultValue: "m" }, + { propertyName: "step", defaultValue: 60 } + ])); + + it("reflects", async () => + reflects(`calcite-input-time-picker`, [ + { propertyName: "active", value: true }, + { propertyName: "disabled", value: true }, + { propertyName: "scale", value: "m" } + ])); + + it("should focus the input when setFocus is called", async () => + focusable(`calcite-input-time-picker`, { + shadowFocusTargetSelector: "input" + })); + + it("opens the time picker on input keyboard focus", async () => { + const page = await newE2EPage({ + html: `` + }); + const popover = await page.find("calcite-input-time-picker >>> calcite-popover"); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect(await popover.getProperty("open")).toBe(true); + }); + + it("opens the time picker on input click", async () => { + const page = await newE2EPage({ + html: `` + }); + const input = await page.find("calcite-input-time-picker >>> calcite-input"); + const popover = await page.find("calcite-input-time-picker >>> calcite-popover"); + + await input.click(); + await page.waitForChanges(); + + expect(await popover.getProperty("open")).toBe(true); + }); + + it("changing hour, minute and second values reflects in the input, input-time-picker and time-picker for 24-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + + const inputTimePicker = await page.find("calcite-input-time-picker"); + const input = await page.find("calcite-input-time-picker >>> calcite-input"); + const timePicker = await page.find("calcite-input-time-picker >>> calcite-time-picker"); + + for (let second = 0; second < 10; second++) { + const date = new Date(0); + date.setSeconds(second); + + const expectedValue = date.toISOString().substr(11, 8); + const expectedHour = expectedValue.substr(0, 2); + const expectedMinute = expectedValue.substr(3, 2); + const expectedSecond = expectedValue.substr(6, 2); + + inputTimePicker.setProperty("value", expectedValue); + + await page.waitForChanges(); + + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); + const timePickerHourValue = await timePicker.getProperty("hour"); + const timePickerMinuteValue = await timePicker.getProperty("minute"); + const timePickerSecondValue = await timePicker.getProperty("second"); + + expect(inputValue).toBe(expectedValue); + expect(inputTimePickerValue).toBe(expectedValue); + expect(timePickerHourValue).toBe(expectedHour); + expect(timePickerMinuteValue).toBe(expectedMinute); + expect(timePickerSecondValue).toBe(expectedSecond); + } + + for (let minute = 0; minute < 10; minute++) { + const date = new Date(0); + date.setMinutes(minute); + + const expectedValue = date.toISOString().substr(11, 8); + const expectedHour = expectedValue.substr(0, 2); + const expectedMinute = expectedValue.substr(3, 2); + const expectedSecond = expectedValue.substr(6, 2); + + inputTimePicker.setProperty("value", expectedValue); + + await page.waitForChanges(); + + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); + const timePickerHourValue = await timePicker.getProperty("hour"); + const timePickerMinuteValue = await timePicker.getProperty("minute"); + const timePickerSecondValue = await timePicker.getProperty("second"); + + expect(inputValue).toBe(expectedValue); + expect(inputTimePickerValue).toBe(expectedValue); + expect(timePickerHourValue).toBe(expectedHour); + expect(timePickerMinuteValue).toBe(expectedMinute); + expect(timePickerSecondValue).toBe(expectedSecond); + } + + for (let hour = 0; hour < 10; hour++) { + const date = new Date(0); + date.setHours(hour); + + const expectedValue = date.toISOString().substr(11, 8); + const expectedHour = expectedValue.substr(0, 2); + const expectedMinute = expectedValue.substr(3, 2); + const expectedSecond = expectedValue.substr(6, 2); + + inputTimePicker.setProperty("value", expectedValue); + + await page.waitForChanges(); + + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); + const timePickerHourValue = await timePicker.getProperty("hour"); + const timePickerMinuteValue = await timePicker.getProperty("minute"); + const timePickerSecondValue = await timePicker.getProperty("second"); + + expect(inputValue).toBe(expectedValue); + expect(inputTimePickerValue).toBe(expectedValue); + expect(timePickerHourValue).toBe(expectedHour); + expect(timePickerMinuteValue).toBe(expectedMinute); + expect(timePickerSecondValue).toBe(expectedSecond); + } + }); + + it("changing hour, minute and second values reflects in the input, input-time-picker and time-picker for 12-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + + const inputTimePicker = await page.find("calcite-input-time-picker"); + const input = await page.find("calcite-input-time-picker >>> calcite-input"); + const timePicker = await page.find("calcite-input-time-picker >>> calcite-time-picker"); + + for (let second = 0; second < 10; second++) { + const date = new Date(0); + date.setSeconds(second); + + const expectedValue = date.toISOString().substr(11, 8); + const expectedHour = expectedValue.substr(0, 2); + const expectedHourAsNumber = parseInt(expectedValue.substr(0, 2)); + const expectedDisplayHour = + expectedHourAsNumber > 12 + ? formatTimePart(expectedHourAsNumber - 12) + : expectedHourAsNumber === 0 + ? "12" + : formatTimePart(expectedHourAsNumber); + const expectedMinute = expectedValue.substr(3, 2); + const expectedSecond = expectedValue.substr(6, 2); + + inputTimePicker.setProperty("value", expectedValue); + + await page.waitForChanges(); + + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); + const timePickerHourValue = await timePicker.getProperty("hour"); + const timePickerMinuteValue = await timePicker.getProperty("minute"); + const timePickerSecondValue = await timePicker.getProperty("second"); + + expect(inputValue).toBe(expectedValue); + expect(inputTimePickerValue).toBe(expectedValue); + expect(timePickerHourValue).toBe(expectedHour); + expect(timePickerMinuteValue).toBe(expectedMinute); + expect(timePickerSecondValue).toBe(expectedSecond); + } + + for (let minute = 0; minute < 10; minute++) { + const date = new Date(0); + date.setMinutes(minute); + + const expectedValue = date.toISOString().substr(11, 8); + const expectedHour = expectedValue.substr(0, 2); + const expectedHourAsNumber = parseInt(expectedValue.substr(0, 2)); + const expectedDisplayHour = + expectedHourAsNumber > 12 + ? formatTimePart(expectedHourAsNumber - 12) + : expectedHourAsNumber === 0 + ? "12" + : formatTimePart(expectedHourAsNumber); + const expectedMinute = expectedValue.substr(3, 2); + const expectedSecond = expectedValue.substr(6, 2); + + inputTimePicker.setProperty("value", expectedValue); + + await page.waitForChanges(); + + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); + const timePickerHourValue = await timePicker.getProperty("hour"); + const timePickerMinuteValue = await timePicker.getProperty("minute"); + const timePickerSecondValue = await timePicker.getProperty("second"); + + expect(inputValue).toBe(expectedValue); + expect(inputTimePickerValue).toBe(expectedValue); + expect(timePickerHourValue).toBe(expectedHour); + expect(timePickerMinuteValue).toBe(expectedMinute); + expect(timePickerSecondValue).toBe(expectedSecond); + } + + for (let hour = 0; hour < 10; hour++) { + const date = new Date(0); + date.setHours(hour); + + const expectedValue = date.toISOString().substr(11, 8); + const expectedHour = expectedValue.substr(0, 2); + const expectedHourAsNumber = parseInt(expectedValue.substr(0, 2)); + const expectedDisplayHour = + expectedHourAsNumber > 12 + ? formatTimePart(expectedHourAsNumber - 12) + : expectedHourAsNumber === 0 + ? "12" + : formatTimePart(expectedHourAsNumber); + const expectedMinute = expectedValue.substr(3, 2); + const expectedSecond = expectedValue.substr(6, 2); + + inputTimePicker.setProperty("value", expectedValue); + + await page.waitForChanges(); + + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); + const timePickerHourValue = await timePicker.getProperty("hour"); + const timePickerMinuteValue = await timePicker.getProperty("minute"); + const timePickerSecondValue = await timePicker.getProperty("second"); + + expect(inputValue).toBe(expectedValue); + expect(inputTimePickerValue).toBe(expectedValue); + expect(timePickerHourValue).toBe(expectedHour); + expect(timePickerMinuteValue).toBe(expectedMinute); + expect(timePickerSecondValue).toBe(expectedSecond); + } + }); + + it("appropriately triggers calciteInputTimePickerChange event when the user types a value", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const inputTimePicker = await page.find("calcite-input-time-picker"); + const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + + expect(changeEvent).toHaveReceivedEventTimes(0); + + await page.keyboard.press("Tab"); + await page.keyboard.press("1"); + await page.keyboard.press(":"); + await page.keyboard.press("2"); + + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + + await page.keyboard.press(":"); + await page.keyboard.press("3"); + + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(2); + }); + + it("formats valid typed time value appropriately on blur", async () => { + const page = await newE2EPage(); + await page.setContent(``); + + const inputTimePicker = await page.find("calcite-input-time-picker"); + + await page.keyboard.press("Tab"); + await page.keyboard.type("2:3:4"); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect(await inputTimePicker.getProperty("value")).toBe("02:03:04"); + }); + + it("resets to previous value when default event behavior is prevented", async () => { + const page = await newE2EPage({ + html: `` + }); + const inputTimePicker = await page.find("calcite-input-time-picker"); + + await page.evaluate(() => { + const inputTimePicker = document.querySelector("calcite-input-time-picker"); + inputTimePicker.addEventListener("calciteInputTimePickerChange", (event) => { + event.preventDefault(); + }); + }); + + expect(await inputTimePicker.getProperty("value")).toBe("14:59"); + + await page.keyboard.press("Tab"); + await page.keyboard.press(":"); + await page.keyboard.press("5"); + await page.waitForChanges(); + + expect(await inputTimePicker.getProperty("value")).toBe("14:59"); + }); +}); diff --git a/src/components/calcite-input-time-picker/calcite-input-time-picker.scss b/src/components/calcite-input-time-picker/calcite-input-time-picker.scss new file mode 100644 index 00000000000..25e59467b9e --- /dev/null +++ b/src/components/calcite-input-time-picker/calcite-input-time-picker.scss @@ -0,0 +1,4 @@ +:host { + @apply inline-block + select-none; +} diff --git a/src/components/calcite-input-time-picker/calcite-input-time-picker.stories.ts b/src/components/calcite-input-time-picker/calcite-input-time-picker.stories.ts new file mode 100644 index 00000000000..ab2e6ebeff8 --- /dev/null +++ b/src/components/calcite-input-time-picker/calcite-input-time-picker.stories.ts @@ -0,0 +1,44 @@ +import { number, select, text } from "@storybook/addon-knobs"; +import { boolean } from "../../../.storybook/helpers"; +import { darkBackground } from "../../../.storybook/utils"; +import readme from "./readme.md"; +import { html } from "../../tests/utils"; + +export default { + title: "Components/Controls/Time/Input Time Picker", + + parameters: { + notes: readme + } +}; + +export const LightTheme = (): string => html` + + +`; + +export const DarkTheme = (): string => html` + + +`; + +DarkTheme.story = { + parameters: { backgrounds: darkBackground } +}; diff --git a/src/components/calcite-input-time-picker/calcite-input-time-picker.tsx b/src/components/calcite-input-time-picker/calcite-input-time-picker.tsx new file mode 100644 index 00000000000..f382584dbf4 --- /dev/null +++ b/src/components/calcite-input-time-picker/calcite-input-time-picker.tsx @@ -0,0 +1,364 @@ +import { + Component, + Element, + Host, + VNode, + h, + Prop, + Listen, + Event, + EventEmitter, + Method, + Watch +} from "@stencil/core"; +import { guid } from "../../utils/guid"; +import { getKey } from "../../utils/key"; +import { parseTimeString, Time, formatTimeString, HourDisplayFormat } from "../../utils/time"; +import { Scale } from "../interfaces"; + +@Component({ + tag: "calcite-input-time-picker", + styleUrl: "calcite-input-time-picker.scss", + shadow: true +}) +export class CalciteInputTimePicker { + //-------------------------------------------------------------------------- + // + // Element + // + //-------------------------------------------------------------------------- + + @Element() el: HTMLCalciteInputTimePickerElement; + + //-------------------------------------------------------------------------- + // + // Properties + // + //-------------------------------------------------------------------------- + + /** The active state of the time input */ + @Prop({ reflect: true, mutable: true }) active?: boolean = false; + + /** The disabled state of the time input */ + @Prop({ reflect: true }) disabled?: boolean = false; + + /** Format of the hour value (12-hour or 24-hour) (this will be replaced by locale eventually) */ + @Prop() hourDisplayFormat: HourDisplayFormat = "12"; + + /** aria-label for the hour input */ + @Prop() intlHour?: string; + + /** aria-label for the hour down button */ + @Prop() intlHourDown?: string; + + /** aria-label for the hour up button */ + @Prop() intlHourUp?: string; + + /** aria-label for the meridiem (am/pm) input */ + @Prop() intlMeridiem?: string; + + /** aria-label for the meridiem (am/pm) down button */ + @Prop() intlMeridiemDown?: string; + + /** aria-label for the meridiem (am/pm) up button */ + @Prop() intlMeridiemUp?: string; + + /** aria-label for the minute input */ + @Prop() intlMinute?: string; + + /** aria-label for the minute down button */ + @Prop() intlMinuteDown?: string; + + /** aria-label for the minute up button */ + @Prop() intlMinuteUp?: string; + + /** aria-label for the second input */ + @Prop() intlSecond?: string; + + /** aria-label for the second down button */ + @Prop() intlSecondDown?: string; + + /** aria-label for the second up button */ + @Prop() intlSecondUp?: string; + + /** The name of the time input */ + @Prop() name?: string; + + /** The scale (size) of the time input */ + @Prop({ reflect: true }) scale: Scale = "m"; + + /** number that specifies the granularity that the value must adhere to */ + @Prop() step = 60; + + /** The selected time */ + @Prop({ mutable: true }) value: string = null; + + @Watch("value") + valueWatcher(newValue: string): void { + if (!this.internalValueChange) { + this.setValue({ value: newValue, origin: "external" }); + } + this.internalValueChange = false; + } + + //-------------------------------------------------------------------------- + // + // Private Properties + // + //-------------------------------------------------------------------------- + + private calciteInputEl: HTMLCalciteInputElement; + + private calciteTimePickerEl: HTMLCalciteTimePickerElement; + + /** whether the value of the input was changed as a result of user typing or not */ + private internalValueChange = false; + + private previousValidValue: string = null; + + private referenceElementId = `input-time-picker-${guid()}`; + + //-------------------------------------------------------------------------- + // + // Events + // + //-------------------------------------------------------------------------- + + /** + * Fires when the time value is changed as a result of user input. + */ + @Event() calciteInputTimePickerChange: EventEmitter; + + //-------------------------------------------------------------------------- + // + // Event Listeners + // + //-------------------------------------------------------------------------- + + private calciteInputBlurHandler = (): void => { + this.active = false; + + const newValue = formatTimeString(this.calciteInputEl.value) || formatTimeString(this.value); + + if (newValue !== this.calciteInputEl.value) { + this.calciteInputEl.value = newValue; + } + }; + + private calciteInputFocusHandler = (): void => { + this.active = true; + }; + + private calciteInputInputHandler = (event: CustomEvent): void => { + this.setValue({ value: event.detail.value }); + }; + + @Listen("click") + clickHandler(event: MouseEvent): void { + if (event.composedPath().includes(this.calciteTimePickerEl)) { + return; + } + this.setFocus(); + } + + @Listen("keyup") + keyUpHandler(event: KeyboardEvent): void { + if (getKey(event.key) === "Escape" && this.active) { + this.active = false; + } + } + + @Listen("calciteTimePickerBlur") + timePickerBlurHandler(event: CustomEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.active = false; + } + + @Listen("calciteTimePickerChange") + timePickerChangeHandler(event: CustomEvent): void { + event.preventDefault(); + event.stopPropagation(); + if (event.detail) { + const { hour, minute, second } = event.detail as Time; + let value; + if (hour && minute) { + if (second && this.step !== 60) { + value = `${hour}:${minute}:${second}`; + } else { + value = `${hour}:${minute}`; + } + } else { + value = ""; + } + this.setValue({ value, origin: "time-picker" }); + } + } + + @Listen("calciteTimePickerFocus") + timePickerFocusHandler(event: CustomEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.active = true; + } + + // -------------------------------------------------------------------------- + // + // Public Methods + // + // -------------------------------------------------------------------------- + + @Method() + async setFocus(): Promise { + this.calciteInputEl.setFocus(); + } + + // -------------------------------------------------------------------------- + // + // Private Methods + // + // -------------------------------------------------------------------------- + + private setCalciteInputEl = (el: HTMLCalciteInputElement): void => { + this.calciteInputEl = el; + }; + + private setCalciteTimePickerEl = (el: HTMLCalciteTimePickerElement): void => { + this.calciteTimePickerEl = el; + }; + + private setInputValue = (newInputValue: string): void => { + if (this.calciteInputEl) { + this.calciteInputEl.value = newInputValue; + } + }; + + private setValue = ({ + value, + origin = "input" + }: { + value: string; + origin?: "input" | "time-picker" | "external" | "loading"; + }): void => { + const previousValue = this.value; + const validatedNewValue = formatTimeString(value); + + this.internalValueChange = origin !== "external" && origin !== "loading"; + + const shouldEmit = + origin !== "loading" && + origin !== "external" && + ((value !== this.previousValidValue && !value) || + !!(!this.previousValidValue && validatedNewValue) || + (validatedNewValue !== this.previousValidValue && validatedNewValue)); + + if (value) { + if (shouldEmit) { + this.previousValidValue = validatedNewValue; + } + if (validatedNewValue && validatedNewValue !== this.value) { + this.value = validatedNewValue; + } + } else { + this.value = value; + } + + if (origin === "time-picker" || origin === "external") { + this.setInputValue(validatedNewValue); + } + + if (shouldEmit) { + const changeEvent = this.calciteInputTimePickerChange.emit(); + + if (changeEvent.defaultPrevented) { + this.internalValueChange = false; + this.value = previousValue; + this.setInputValue(previousValue); + this.previousValidValue = previousValue; + } else { + this.previousValidValue = validatedNewValue; + } + } + }; + + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + connectedCallback() { + if (this.value) { + this.setValue({ value: this.value, origin: "loading" }); + } + } + + componentDidLoad() { + if (this.calciteInputEl.value !== this.value) { + this.setInputValue(this.value); + } + } + + // -------------------------------------------------------------------------- + // + // Render Methods + // + // -------------------------------------------------------------------------- + + render(): VNode { + const { hour, minute, second } = parseTimeString(this.value); + const popoverId = `${this.referenceElementId}-popover`; + return ( + +
+ +
+ + + +
+ ); + } +} diff --git a/src/components/calcite-input-time-picker/readme.md b/src/components/calcite-input-time-picker/readme.md new file mode 100644 index 00000000000..896bc775773 --- /dev/null +++ b/src/components/calcite-input-time-picker/readme.md @@ -0,0 +1,55 @@ +# calcite-input-time-picker + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------------- | --------------------- | ------------------------------------------------------------------------------------------ | ------------------- | ----------- | +| `disabled` | `disabled` | The disabled state of the time input | `boolean` | `false` | +| `hourDisplayFormat` | `hour-display-format` | Format of the hour value (12-hour or 24-hour) (this will be replaced by locale eventually) | `"12" \| "24"` | `"12"` | +| `name` | `name` | The name of the time input | `string` | `undefined` | +| `scale` | `scale` | The scale (size) of the time input | `"l" \| "m" \| "s"` | `"m"` | +| `step` | `step` | number that specifies the granularity that the value must adhere to | `number` | `60` | +| `theme` | `theme` | The color theme of the time-picker | `"dark" \| "light"` | `undefined` | +| `value` | `value` | The selected time | `string` | `undefined` | + +## Events + +| Event | Description | Type | +| ------------------------------ | -------------------------------------- | --------------------- | +| `calciteInputTimePickerChange` | Fires when the time value has changed. | `CustomEvent` | + +## Methods + +### `setFocus() => Promise` + +#### Returns + +Type: `Promise` + +## Dependencies + +### Depends on + +- [calcite-input](../calcite-input) +- [calcite-popover](../calcite-popover) +- [calcite-time-picker](../calcite-time-picker) + +### Graph + +```mermaid +graph TD; + calcite-input-time-picker --> calcite-input + calcite-input-time-picker --> calcite-popover + calcite-input-time-picker --> calcite-time-picker + calcite-input --> calcite-progress + calcite-input --> calcite-icon + calcite-popover --> calcite-icon + calcite-time-picker --> calcite-icon + style calcite-input-time-picker fill:#f9f,stroke:#333,stroke-width:4px +``` + +--- + +_Built with [StencilJS](https://stenciljs.com/)_ diff --git a/src/components/calcite-input/calcite-input.tsx b/src/components/calcite-input/calcite-input.tsx index aff7dfc6f93..16277807343 100644 --- a/src/components/calcite-input/calcite-input.tsx +++ b/src/components/calcite-input/calcite-input.tsx @@ -126,6 +126,7 @@ export class CalciteInput { /** Minimum length of the text input */ @Prop({ reflect: true }) minLength?: number; + /** The name of the input */ @Prop({ reflect: true }) name?: string; /** specify the placement of the number buttons */ @@ -185,6 +186,8 @@ export class CalciteInput { this.localizedValue !== localizeNumberString(newValue, this.locale) ) { this.setLocalizedValue(newValue); + } else if (this.childEl && this.childEl.value !== newValue) { + this.childEl.value = newValue; } } @@ -215,7 +218,7 @@ export class CalciteInput { private form: HTMLFormElement; get isClearable(): boolean { - return !this.isTextarea && (this.clearable || this.type === "search") && this.value.length > 0; + return !this.isTextarea && (this.clearable || this.type === "search") && this.value?.length > 0; } get isTextarea(): boolean { @@ -278,7 +281,7 @@ export class CalciteInput { componentDidLoad(): void { this.slottedActionEl = this.el.querySelector("[slot=action]"); this.setDisabledAction(); - if (this.type === "number") { + if (this.type === "number" && this.childEl) { this.childEl.style.cssText = hiddenInputStyle; } } @@ -308,12 +311,12 @@ export class CalciteInput { @Event() calciteInputBlur: EventEmitter; /** - * This event fires as the value of the input changes. + * This event fires each time a new value is typed. */ @Event({ cancelable: true }) calciteInputInput: EventEmitter; /** - * This event fires when the value of the input changes and is committed. + * This event fires each time a new value is typed and committed. * @internal */ @Event() calciteInputChange: EventEmitter; @@ -484,11 +487,11 @@ export class CalciteInput { this.nudgeNumberValue(direction, event); }; - private reset = (event): void => { + private reset = (nativeEvent): void => { if (this.type === "number") { - event.preventDefault(); + nativeEvent.preventDefault(); } - this.setValue(this.defaultValue, event); + this.setValue(this.defaultValue, nativeEvent); }; private setChildElRef = (el) => { @@ -514,24 +517,32 @@ export class CalciteInput { this.localizedValue = localizeNumberString(value, this.locale, this.groupSeparator); }; - private setValue = (value: string, nativeEvent, committing = false): void => { + private setValue = (value: string, nativeEvent?: any, committing = false): void => { const previousValue = this.value; + this.value = this.type === "number" ? sanitizeDecimalString(value) : value; - this.setLocalizedValue(this.value); - if (this.type === "number" && value?.endsWith(".")) { - return; + + if (this.type === "number") { + this.setLocalizedValue(this.value); } - const calciteInputInputEvent = this.calciteInputInput.emit({ - element: this.childEl, - nativeEvent, - value - }); - if (calciteInputInputEvent.defaultPrevented) { - this.value = previousValue; - this.setLocalizedValue(previousValue); - } else if (committing) { - this.calciteInputChange.emit(); + if (nativeEvent) { + if (this.type === "number" && value?.endsWith(".")) { + return; + } + + const calciteInputInputEvent = this.calciteInputInput.emit({ + element: this.childEl, + nativeEvent, + value + }); + + if (calciteInputInputEvent.defaultPrevented) { + this.value = previousValue; + this.setLocalizedValue(previousValue); + } else if (committing) { + this.calciteInputChange.emit(); + } } }; diff --git a/src/components/calcite-popover/calcite-popover.scss b/src/components/calcite-popover/calcite-popover.scss index 304c72ff61a..f6ea51865e4 100644 --- a/src/components/calcite-popover/calcite-popover.scss +++ b/src/components/calcite-popover/calcite-popover.scss @@ -48,12 +48,12 @@ } .container { - @apply bg-foreground-1 - relative - flex - overflow-hidden - flex-no-wrap - flex-row + @apply bg-foreground-1 + relative + flex + overflow-hidden + flex-no-wrap + flex-row h-full text-color-1 rounded; diff --git a/src/components/calcite-popover/readme.md b/src/components/calcite-popover/readme.md index 7ae45afaa51..8b4023bcc52 100644 --- a/src/components/calcite-popover/readme.md +++ b/src/components/calcite-popover/readme.md @@ -71,6 +71,7 @@ Type: `Promise` ### Used by - [calcite-action-menu](../calcite-action-menu) +- [calcite-input-time-picker](../calcite-input-time-picker) ### Depends on @@ -86,6 +87,7 @@ graph TD; calcite-action --> calcite-loader calcite-action --> calcite-icon calcite-action-menu --> calcite-popover + calcite-input-time-picker --> calcite-popover style calcite-popover fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/components/calcite-time-picker/calcite-time-picker.e2e.ts b/src/components/calcite-time-picker/calcite-time-picker.e2e.ts new file mode 100644 index 00000000000..9ff04ead060 --- /dev/null +++ b/src/components/calcite-time-picker/calcite-time-picker.e2e.ts @@ -0,0 +1,1058 @@ +import { newE2EPage } from "@stencil/core/testing"; +import { accessible, defaults, focusable, reflects, renders } from "../../tests/commonTests"; +import { formatTimePart } from "../../utils/time"; +import { CSS } from "./resources"; + +const letterKeys = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z" +]; + +describe("calcite-time-picker", () => { + it("renders", async () => renders("calcite-time-picker")); + + it("is accessible", async () => accessible(``)); + + it("has defaults", async () => + defaults("calcite-time-picker", [ + { propertyName: "hour", defaultValue: null }, + { propertyName: "hourDisplayFormat", defaultValue: "12" }, + { propertyName: "minute", defaultValue: null }, + { propertyName: "second", defaultValue: null }, + { propertyName: "scale", defaultValue: "m" }, + { propertyName: "step", defaultValue: 60 } + ])); + + it("reflects", async () => + reflects("calcite-time-picker", [ + { propertyName: "hourDisplayFormat", value: "12" }, + { propertyName: "scale", value: "m" }, + { propertyName: "step", value: 60 } + ])); + + it("should focus the first input when setFocus is called", async () => + focusable(`calcite-time-picker`, { + shadowFocusTargetSelector: `.${CSS.hour}` + })); + + describe("keyboard accessibility", () => { + it("tabbing focuses each input in the correct sequence", async () => { + const page = await newE2EPage({ + html: `` + }); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.hour}` + ) + ).toBe(true); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.minute}` + ) + ).toBe(true); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.second}` + ) + ).toBe(true); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.meridiem}` + ) + ).toBe(true); + }); + + it("pressing right and left arrow keys focuses each input in the correct sequence", async () => { + const page = await newE2EPage({ + html: `` + }); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.hour}` + ) + ).toBe(true); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.minute}` + ) + ).toBe(true); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.second}` + ) + ).toBe(true); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.meridiem}` + ) + ).toBe(true); + + await page.keyboard.press("ArrowLeft"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.second}` + ) + ).toBe(true); + + await page.keyboard.press("ArrowLeft"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.minute}` + ) + ).toBe(true); + + await page.keyboard.press("ArrowLeft"); + await page.waitForChanges(); + + expect( + await page.$eval( + "calcite-time-picker", + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + `.${CSS.hour}` + ) + ).toBe(true); + }); + + it("ArrowUp key increments hour property and display hour correctly for 24-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + + await hour.click(); + + for (let i = 1; i < 24; i++) { + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(formatTimePart(i)); + } + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("00"); + }); + + it("ArrowDown key decrements hour property and display hour correctly for 24-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + + await hour.click(); + await page.keyboard.press("ArrowDown"); + + expect(await timePicker.getProperty("hour")).toBe("00"); + + for (let i = 23; i > 0; i--) { + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(formatTimePart(i)); + } + }); + + it("ArrowUp key increments hour property and display hour correctly for 12-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + + await hour.click(); + + for (let i = 1; i < 24; i++) { + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(i > 12 ? formatTimePart(i - 12) : formatTimePart(i)); + } + + await page.keyboard.press("ArrowUp"); + + expect(await timePicker.getProperty("hour")).toBe("00"); + }); + + it("ArrowDown key decrements hour property and display hour correctly for 12-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + + await hour.click(); + await page.keyboard.press("ArrowDown"); + + expect(await timePicker.getProperty("hour")).toBe("00"); + + for (let i = 23; i > 0; i--) { + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(i > 12 ? formatTimePart(i - 12) : formatTimePart(i)); + } + }); + + it("ArrowUp key increments minute property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + + await minute.click(); + + for (let i = 0; i < 60; i++) { + await page.keyboard.press("ArrowUp"); + expect(await timePicker.getProperty("minute")).toBe(`${formatTimePart(i)}`); + } + await page.keyboard.press("ArrowUp"); + expect(await timePicker.getProperty("minute")).toBe("00"); + }); + + it("ArrowDown key decrements minute property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + + await minute.click(); + + for (let i = 59; i >= 0; i--) { + await page.keyboard.press("ArrowDown"); + expect(await timePicker.getProperty("minute")).toBe(`${formatTimePart(i)}`); + } + await page.keyboard.press("ArrowDown"); + expect(await timePicker.getProperty("minute")).toBe("59"); + }); + + it("ArrowUp key increments second property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + + await second.click(); + + for (let i = 0; i < 60; i++) { + await page.keyboard.press("ArrowUp"); + expect(await timePicker.getProperty("second")).toBe(`${formatTimePart(i)}`); + } + await page.keyboard.press("ArrowUp"); + expect(await timePicker.getProperty("second")).toBe("00"); + }); + + it("ArrowDown key decrements second property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + + await second.click(); + + for (let i = 59; i >= 0; i--) { + await page.keyboard.press("ArrowDown"); + expect(await timePicker.getProperty("second")).toBe(`${formatTimePart(i)}`); + } + await page.keyboard.press("ArrowDown"); + expect(await timePicker.getProperty("second")).toBe("59"); + }); + + it("ArrowUp key increments meridiem property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + await meridiem.click(); + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("AM"); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("PM"); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("AM"); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("--"); + }); + + it("ArrowDown key decrements meridiem property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + await meridiem.click(); + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("PM"); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("AM"); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("PM"); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("--"); + }); + + it("typing letter keys changes nothing for hour, minute and second in 24-hour format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + + await hour.click(); + + for (let i = 0; i >= letterKeys.length; i++) { + await page.keyboard.press(letterKeys[i]); + expect(await timePicker.getProperty("hour")).toBe(null); + expect(hour.textContent).toBe("--"); + } + + await minute.click(); + + for (let i = 0; i >= letterKeys.length; i++) { + await page.keyboard.press(letterKeys[i]); + expect(await timePicker.getProperty("minute")).toBe(null); + expect(minute.textContent).toBe("--"); + } + + await second.click(); + + for (let i = 0; i >= letterKeys.length; i++) { + await page.keyboard.press(letterKeys[i]); + expect(await timePicker.getProperty("second")).toBe(null); + expect(second.textContent).toBe("--"); + } + }); + + it("typing letter keys changes nothing for hour, minute and second in 12-hour format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + + await hour.click(); + + for (let i = 0; i >= letterKeys.length; i++) { + await page.keyboard.press(letterKeys[i]); + expect(await timePicker.getProperty("hour")).toBe(null); + expect(hour.textContent).toBe("--"); + } + + await minute.click(); + + for (let i = 0; i >= letterKeys.length; i++) { + await page.keyboard.press(letterKeys[i]); + expect(await timePicker.getProperty("minute")).toBe(null); + expect(minute.textContent).toBe("--"); + } + + await second.click(); + + for (let i = 0; i >= letterKeys.length; i++) { + await page.keyboard.press(letterKeys[i]); + expect(await timePicker.getProperty("second")).toBe(null); + expect(second.textContent).toBe("--"); + } + }); + + it("allows typing single digit values for hour, minute and second and pads the value and display with a leading zero for 24-hour format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + + await page.keyboard.press("Tab"); + + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i.toString()); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`0${i}`); + expect(hour.textContent).toBe(`0${i}`); + + await page.keyboard.press("Backspace"); + } + + await page.keyboard.press("Tab"); + + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i.toString()); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe(`0${i}`); + expect(minute.textContent).toBe(`0${i}`); + + await page.keyboard.press("Backspace"); + } + + await page.keyboard.press("Tab"); + + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i.toString()); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe(`0${i}`); + expect(second.textContent).toBe(`0${i}`); + + await page.keyboard.press("Backspace"); + } + }); + + it("allows typing single digit values for hour, minute and second and pads the value and display with a leading zero for 12-hour format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + + await page.keyboard.press("Tab"); + + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i.toString()); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`0${i}`); + expect(hour.textContent).toBe(`0${i}`); + + await page.keyboard.press("Backspace"); + } + + await page.keyboard.press("Tab"); + + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i.toString()); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe(`0${i}`); + expect(minute.textContent).toBe(`0${i}`); + + await page.keyboard.press("Backspace"); + } + + await page.keyboard.press("Tab"); + + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i.toString()); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe(`0${i}`); + expect(second.textContent).toBe(`0${i}`); + + await page.keyboard.press("Backspace"); + } + }); + + it("restricts typing to valid hour values for 12-hour format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + + await hour.click(); + + for (let i = 10; i < 13; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${key1}${key2}`); + expect(hour.textContent).toBe(`${key1}${key2}`); + } + + for (let i = 13; i < 100; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`0${key2}`); + expect(hour.textContent).toBe(`0${key2}`); + } + }); + + it("restricts typing to valid hour values for 24-hour format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + + await hour.click(); + + for (let i = 10; i < 24; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${key1}${key2}`); + expect(hour.textContent).toBe(`${key1}${key2}`); + } + + for (let i = 24; i < 100; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`0${key2}`); + expect(hour.textContent).toBe(`0${key2}`); + } + }); + + it("restricts typing to valid minute values", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + + await minute.click(); + + for (let i = 10; i < 60; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe(`${key1}${key2}`); + expect(minute.textContent).toBe(`${key1}${key2}`); + } + + for (let i = 60; i < 100; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe(`0${key2}`); + expect(minute.textContent).toBe(`0${key2}`); + } + }); + + it("restricts typing to valid second values", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + + await second.click(); + + for (let i = 10; i < 60; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe(`${key1}${key2}`); + expect(second.textContent).toBe(`${key1}${key2}`); + } + + for (let i = 60; i < 100; i++) { + const [key1, key2] = i.toString().split(""); + + await page.keyboard.press(key1); + await page.keyboard.press(key2); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe(`0${key2}`); + expect(second.textContent).toBe(`0${key2}`); + } + }); + + it("allows typing 00 for hour in 12-hour format, but when the hour is blurred, the display hour is updated to show 12", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + await hour.click(); + await page.keyboard.press("0"); + await page.keyboard.press("0"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("00"); + expect(meridiem.textContent).toBe("AM"); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("AM"); + }); + + it("allows typing AM and PM for 12-hour format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + await meridiem.click(); + await page.keyboard.press("a"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("AM"); + + await page.keyboard.press("p"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("12"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("PM"); + }); + + it("typing am and pm multiple times when they are already set doesn't affect the hour", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + await meridiem.click(); + await page.keyboard.press("a"); + await page.keyboard.press("a"); + await page.keyboard.press("a"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("AM"); + + await page.keyboard.press("p"); + await page.keyboard.press("p"); + await page.keyboard.press("p"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("12"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("PM"); + }); + }); + + describe("time behavior", () => { + it("hour, display hour and AM/PM set correctly as hour changes for 12-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + expect(meridiem.textContent).toBe("AM"); + + await hour.click(); + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("23"); + expect(hour.textContent).toBe("11"); + expect(meridiem.textContent).toBe("PM"); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("AM"); + }); + + it("changing AM/PM updates hour property correctly for 12-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(meridiem.textContent).toBe("AM"); + + await meridiem.click(); + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("12"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("PM"); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("12"); + expect(meridiem.textContent).toBe("AM"); + }); + + it("hour-up button increments hour property and display hour correctly for 24-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const hourUp = await page.find(`calcite-time-picker >>> .${CSS.buttonHourUp}`); + + for (let i = 1; i < 24; i++) { + await hourUp.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(formatTimePart(i)); + } + + await hourUp.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("00"); + }); + + it("hour-down button decrements hour property and display hour correctly for 24-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const hourdown = await page.find(`calcite-time-picker >>> .${CSS.buttonHourDown}`); + + await hourdown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("00"); + + for (let i = 23; i > 0; i--) { + await hourdown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(formatTimePart(i)); + } + }); + + it("hour-up button increments hour property and display hour correctly for 12-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const hourup = await page.find(`calcite-time-picker >>> .${CSS.buttonHourUp}`); + + for (let i = 1; i < 24; i++) { + await hourup.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(i > 12 ? formatTimePart(i - 12) : formatTimePart(i)); + } + + await hourup.click(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + }); + + it("hour-down button decrements hour property and display hour correctly for 12-hour display format", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const hour = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const hourdown = await page.find(`calcite-time-picker >>> .${CSS.buttonHourDown}`); + + await hourdown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe("00"); + expect(hour.textContent).toBe("12"); + + for (let i = 23; i > 0; i--) { + await hourdown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("hour")).toBe(`${formatTimePart(i)}`); + expect(hour.textContent).toBe(i > 12 ? formatTimePart(i - 12) : formatTimePart(i)); + } + }); + + it("minute-up button increments minute property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const minuteup = await page.find(`calcite-time-picker >>> .${CSS.buttonMinuteUp}`); + + for (let i = 0; i < 60; i++) { + await minuteup.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe(`${formatTimePart(i)}`); + expect(minute.textContent).toBe(`${formatTimePart(i)}`); + } + + await minuteup.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe("00"); + expect(minute.textContent).toBe("00"); + }); + + it("minute-down button decrements minute property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const minute = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const minutedown = await page.find(`calcite-time-picker >>> .${CSS.buttonMinuteDown}`); + + for (let i = 59; i >= 0; i--) { + await minutedown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe(`${formatTimePart(i)}`); + expect(minute.textContent).toBe(`${formatTimePart(i)}`); + } + + await minutedown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("minute")).toBe("59"); + expect(minute.textContent).toBe("59"); + }); + + it("second-up button increments second property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + const secondup = await page.find(`calcite-time-picker >>> .${CSS.buttonSecondUp}`); + + for (let i = 0; i < 60; i++) { + await secondup.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe(`${formatTimePart(i)}`); + expect(second.textContent).toBe(`${formatTimePart(i)}`); + } + + await secondup.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe("00"); + expect(second.textContent).toBe("00"); + }); + + it("second-down button decrements second property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const timePicker = await page.find("calcite-time-picker"); + const second = await page.find(`calcite-time-picker >>> .${CSS.second}`); + const seconddown = await page.find(`calcite-time-picker >>> .${CSS.buttonSecondDown}`); + + for (let i = 59; i >= 0; i--) { + await seconddown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe(`${formatTimePart(i)}`); + expect(second.textContent).toBe(`${formatTimePart(i)}`); + } + + await seconddown.click(); + await page.waitForChanges(); + + expect(await timePicker.getProperty("second")).toBe("59"); + expect(second.textContent).toBe("59"); + }); + + it("meridiem-up button increments meridiem property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + const meridiemup = await page.find(`calcite-time-picker >>> .${CSS.buttonMeridiemUp}`); + + await meridiemup.click(); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("AM"); + + await meridiemup.click(); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("PM"); + + await meridiemup.click(); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("AM"); + }); + + it("meridiem-down button decrements meridiem property correctly", async () => { + const page = await newE2EPage({ + html: `` + }); + const meridiem = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + const meridiemdown = await page.find(`calcite-time-picker >>> .${CSS.buttonMeridiemDown}`); + + await meridiemdown.click(); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("PM"); + + await meridiemdown.click(); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("AM"); + + await meridiemdown.click(); + await page.waitForChanges(); + + expect(meridiem.textContent).toBe("PM"); + }); + }); +}); diff --git a/src/components/calcite-time-picker/calcite-time-picker.scss b/src/components/calcite-time-picker/calcite-time-picker.scss new file mode 100644 index 00000000000..e748edeb4c6 --- /dev/null +++ b/src/components/calcite-time-picker/calcite-time-picker.scss @@ -0,0 +1,154 @@ +:host { + @apply text-color-1 + inline-block + font-medium + select-none; +} + +.time-picker { + @apply shadow-2 + items-center + flex; + border-radius: var(--calcite-border-radius); +} + +span { + @apply items-center + bg-foreground-1 + inline-flex + justify-center; + + &.button { + @apply cursor-pointer; + &:hover, + &:focus { + @apply bg-foreground-2; + } + &:focus { + @apply outline-none; + } + &:active { + @apply bg-foreground-3; + } + &.top-left { + border-top-left-radius: var(--calcite-border-radius); + } + &.bottom-left { + border-bottom-left-radius: var(--calcite-border-radius); + } + &.top-right { + border-top-right-radius: var(--calcite-border-radius); + } + &.bottom-right { + border-bottom-right-radius: var(--calcite-border-radius); + } + calcite-icon { + @apply text-color-3; + } + } + + &.input { + @apply font-medium relative; + &:hover { + box-shadow: inset 0 0 0 2px var(--calcite-ui-foreground-2); + } + &:focus, + &:hover:focus { + @apply outline-none; + box-shadow: inset 0 0 0 2px var(--calcite-ui-brand); + } + } +} + +:host([scale="s"]) { + @apply text--1; + span { + height: 24px; + width: 40px; + } + .delimiter { + height: 72px; + } +} +:host([scale="s"][hour-display-format="12"]) { + .time-picker { + width: 124.2px; + } +} +:host([scale="s"][hour-display-format="12"]:not([step="60"])) { + .time-picker { + width: 168px; + } +} +:host([scale="s"][hour-display-format="24"]) { + .time-picker { + width: 84.2px; + } +} +:host([scale="s"][hour-display-format="24"]:not([step="60"])) { + .time-picker { + width: 128.4px; + } +} + +:host([scale="m"]) { + @apply text-0; + span { + height: 32px; + width: 44px; + } + .delimiter { + height: 96px; + } +} +:host([scale="m"][hour-display-format="12"]) { + .time-picker { + width: 136.8px; + } +} +:host([scale="m"][hour-display-format="12"]:not([step="60"])) { + .time-picker { + width: 186px; + } +} +:host([scale="m"][hour-display-format="24"]) { + .time-picker { + width: 92.8px; + } +} +:host([scale="m"][hour-display-format="24"]:not([step="60"])) { + .time-picker { + width: 141.6px; + } +} + +:host([scale="l"]) { + @apply text-1; + span { + height: 48px; + width: 64px; + } + .delimiter { + height: 144px; + } +} +:host([scale="l"][hour-display-format="12"]) { + .time-picker { + width: 198px; + } +} +:host([scale="l"][hour-display-format="12"]:not([step="60"])) { + .time-picker { + width: 268px; + } +} +:host([scale="l"][hour-display-format="24"]) { + .time-picker { + width: 134px; + } +} +:host([scale="l"][hour-display-format="24"]:not([step="60"])) { + .time-picker { + width: 204px; + } +} diff --git a/src/components/calcite-time-picker/calcite-time-picker.tsx b/src/components/calcite-time-picker/calcite-time-picker.tsx new file mode 100644 index 00000000000..c35f38df736 --- /dev/null +++ b/src/components/calcite-time-picker/calcite-time-picker.tsx @@ -0,0 +1,835 @@ +import { + Component, + Element, + Host, + h, + Prop, + VNode, + Event, + EventEmitter, + Watch, + State, + Listen, + Method +} from "@stencil/core"; +import { Scale } from "../interfaces"; +import { getKey, isActivationKey, isSpacebarKey, numberKeys } from "../../utils/key"; +import { isValidNumber } from "../../utils/number"; +import { + Meridiem, + formatTimePart, + MinuteOrSecond, + Time, + maxTenthForMinuteAndSecond, + TimeFocusId, + getMeridiem, + getMeridiemHour, + HourDisplayFormat +} from "../../utils/time"; +import { CSS, TEXT } from "./resources"; + +@Component({ + tag: "calcite-time-picker", + styleUrl: "calcite-time-picker.scss", + shadow: true +}) +export class CalciteTimePicker { + //-------------------------------------------------------------------------- + // + // Element + // + //-------------------------------------------------------------------------- + + @Element() el: HTMLCalciteTimePickerElement; + + //-------------------------------------------------------------------------- + // + // Properties + // + //-------------------------------------------------------------------------- + + /** The hour value (24-hour format) */ + @Prop({ mutable: true }) hour?: string = null; + + /** Format of the hour value (12-hour or 24-hour) (this will be replaced by locale eventually) */ + @Prop({ reflect: true }) hourDisplayFormat: HourDisplayFormat = "12"; + + /** aria-label for the hour input */ + @Prop() intlHour = TEXT.hour; + + /** aria-label for the hour down button */ + @Prop() intlHourDown = TEXT.hourDown; + + /** aria-label for the hour up button */ + @Prop() intlHourUp = TEXT.hourUp; + + /** aria-label for the meridiem (am/pm) input */ + @Prop() intlMeridiem = TEXT.meridiem; + + /** aria-label for the meridiem (am/pm) down button */ + @Prop() intlMeridiemDown = TEXT.meridiemDown; + + /** aria-label for the meridiem (am/pm) up button */ + @Prop() intlMeridiemUp = TEXT.meridiemUp; + + /** aria-label for the minute input */ + @Prop() intlMinute = TEXT.minute; + + /** aria-label for the minute down button */ + @Prop() intlMinuteDown = TEXT.minuteDown; + + /** aria-label for the minute up button */ + @Prop() intlMinuteUp = TEXT.minuteUp; + + /** aria-label for the second input */ + @Prop() intlSecond = TEXT.second; + + /** aria-label for the second down button */ + @Prop() intlSecondDown = TEXT.secondDown; + + /** aria-label for the second up button */ + @Prop() intlSecondUp = TEXT.secondUp; + + /** The minute value */ + @Prop({ mutable: true }) minute?: string = null; + + /** The second value */ + @Prop({ mutable: true }) second?: string = null; + + /** The scale (size) of the time picker */ + @Prop({ reflect: true }) scale: Scale = "m"; + + /** number that specifies the granularity that the value must adhere to */ + @Prop({ reflect: true }) step = 60; + + @Watch("hour") + hourChanged(newHour: string): void { + if (this.hourDisplayFormat === "12" && isValidNumber(newHour)) { + this.meridiem = getMeridiem(newHour); + } + } + + @Watch("hour") + @Watch("minute") + @Watch("second") + timeChangeHandler(): void { + const { hour, minute } = this.getTime(); + if (!hour && !minute) { + this.setTime("meridiem", null, false); + } + if (this.timeChanged) { + this.timeChanged = false; + } + } + + // -------------------------------------------------------------------------- + // + // Private Properties + // + // -------------------------------------------------------------------------- + + private activeEl: HTMLSpanElement; + + private meridiemEl: HTMLSpanElement; + + private hourEl: HTMLSpanElement; + + private minuteEl: HTMLSpanElement; + + private secondEl: HTMLSpanElement; + + private timeChanged = false; + + // -------------------------------------------------------------------------- + // + // State + // + // -------------------------------------------------------------------------- + + /** The am/pm value */ + @State() meridiem: Meridiem = null; + + @State() displayHour: string = this.getDisplayHour(); + + @State() editingHourWhileFocused = false; + + //-------------------------------------------------------------------------- + // + // Events + // + //-------------------------------------------------------------------------- + + /** + * @internal + */ + @Event() calciteTimePickerBlur: EventEmitter