Skip to content

feat(input-time-picker): support fractional seconds #7532

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 74 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
275ad96
feat(time-picker): stubbing out initial boilerplate markup for showin…
eriklharper Jul 25, 2023
1f4ea0f
adding getDecimalPlaces number util since we're going to need this to…
eriklharper Jul 25, 2023
d2b6c27
adding millisecond entries to default messages
eriklharper Jul 25, 2023
187f7a2
adding more test cases for getDecimalPlaces
eriklharper Jul 25, 2023
b287eae
displaying localized millisecond value on initial load
eriklharper Jul 26, 2023
1956cd6
updating parseTimeString to support fractional seconds, adding time t…
eriklharper Jul 26, 2023
c5adde6
Merge branch 'eriklharper/time-milliseconds-support' of github.com:Es…
eriklharper Jul 26, 2023
caf04e9
refactoring from millisecond to fractionalSecond
eriklharper Jul 26, 2023
196b452
setting aria value ranges to accommodate any millisecond value
eriklharper Jul 27, 2023
2d072e2
renaming millisecond to fractionalSecond
eriklharper Jul 27, 2023
a68c024
getRealDecimalPlaces now returns the true amount of decimal places ta…
eriklharper Jul 27, 2023
cdb3e4e
fixing parseTimeString to return correct fractionalSecond, fixing loc…
eriklharper Jul 28, 2023
d11cc4b
fixing localizeTimeStringToParts to stop calling toFixed on the alrea…
eriklharper Jul 28, 2023
3ed449f
initializing localized fractional second and decimal values to null f…
eriklharper Jul 28, 2023
94befb6
updating math.decimalPlaces() to return false for numbers that have d…
eriklharper Jul 31, 2023
4074f6a
using decimalPlaces instead of getRealDecimalPlacesCount
eriklharper Jul 31, 2023
9edd1f6
getting the basic logic in place to increment fractional second
eriklharper Aug 1, 2023
853794b
simplifying increment logic, renaming to toggleSecond for clearer mea…
eriklharper Aug 1, 2023
a1182e6
initializeValue method rounds initial value to the nearest fractional…
eriklharper Aug 1, 2023
2c5f5a7
updating formatTimePart to support fractional seconds and updating in…
eriklharper Aug 2, 2023
0bf9ee1
adding basic decrement support, refactoring to use a single method fo…
eriklharper Aug 8, 2023
0081e1a
removing initializeValue in favor of setValue
eriklharper Aug 9, 2023
ba344c0
localizeTimeStringToParts: always return localizedDecimalSeparator
eriklharper Aug 10, 2023
ee649ab
fixing issue where localizeTimeStringToParts wasn't returning decimal…
eriklharper Aug 10, 2023
e0aac93
localizeTimeStringToParts returns fractional second with padded zeros…
eriklharper Aug 10, 2023
104e29a
formatTimePart tests
eriklharper Aug 11, 2023
3cfd06f
feat(time-picker): upward nudge of empty fractional second sets to 0
eriklharper Aug 11, 2023
e1dcb13
feat(time-picker): when min or max value is nudged, set to the opposi…
eriklharper Aug 14, 2023
0d7d3df
feat(time-picker): always display localized h, m, s suffixes and deci…
eriklharper Aug 14, 2023
72d0da8
removing console.log I missed
eriklharper Aug 14, 2023
2b603c0
fractional second keyboard navigation support
eriklharper Aug 14, 2023
9ce3731
removing up/down button keydown handlers since they're not supposed t…
eriklharper Aug 14, 2023
e8d85b7
nudging fractional second works with up down arrow keys
eriklharper Aug 14, 2023
2d007fb
parseTimeString simplified to return values WYSIWYG style instead of …
eriklharper Aug 15, 2023
19c9f09
basic support for typing non-zero values for fractional second
eriklharper Aug 15, 2023
6e7fb46
handle typing zero properly
eriklharper Aug 15, 2023
f9a88e6
aria-valuenow support
eriklharper Aug 15, 2023
21d4e65
adding fractional second support to localizeTimePart, updating nudge …
eriklharper Aug 15, 2023
333b7a9
sanitizing initial value to round off fractional seconds that are gre…
eriklharper Aug 15, 2023
27f3df8
Merge branch 'main' of github.com:Esri/calcite-design-system into eri…
eriklharper Aug 21, 2023
7a6760c
fixing bug where you couldn't set hour to 10
eriklharper Aug 21, 2023
bcf5875
render localized trailing zeros based on step precision
eriklharper Aug 21, 2023
d54325d
only emit change event when full value changes
eriklharper Aug 21, 2023
92af799
adding fractional second support to formatTimeString, localizeTimeStr…
eriklharper Aug 22, 2023
33593b6
fixing toggleSecond logic
eriklharper Aug 22, 2023
80bbb68
updating locale config to replace each LTS seconds identifier to incl…
eriklharper Aug 22, 2023
e6a425d
nudging fractional second up from null value sets to 0 like native br…
eriklharper Aug 22, 2023
5b5e522
correcting down nudging logic to calculate the difference between 1 a…
eriklharper Aug 22, 2023
138c01b
feat(input-time-picker): allow typing and committing a localized time…
eriklharper Aug 23, 2023
cb6c5c3
localize fractional second in localizeTimeString
eriklharper Aug 23, 2023
fc6e4f1
fix: allow typing non-fractional second values when step includes fra…
eriklharper Aug 24, 2023
5a67658
Merge branch 'main' of github.com:Esri/calcite-design-system into eri…
eriklharper Aug 24, 2023
9e31d89
fixing existing tests
eriklharper Aug 24, 2023
f0a08d0
Merge branch 'main' of github.com:Esri/calcite-design-system into eri…
eriklharper Aug 24, 2023
c245cd1
refactoring localizeTimeString to use fractionalSecondDigits config o…
eriklharper Aug 29, 2023
c02a4e2
refactoring to prefer const eslint rule
eriklharper Aug 29, 2023
fb371e2
adding ref explainer
eriklharper Aug 29, 2023
57fb721
fixing critical issue where seconds weren't returning correctly when …
eriklharper Aug 30, 2023
f643d33
refactoring away from using bound functions in render
eriklharper Aug 30, 2023
02a29f6
adding millisecond examples to demo page
eriklharper Aug 30, 2023
22a8ce6
adding new messages to messages_en.json too
eriklharper Aug 30, 2023
7b9108f
adding increment and decrement functions to avoid binding and creatin…
eriklharper Aug 30, 2023
b5b8d64
adding stories
eriklharper Aug 30, 2023
31a4026
Merge branch 'main' of github.com:Esri/calcite-design-system into eri…
eriklharper Aug 30, 2023
3bc9c30
nudge fractional seconds by just the fractional portion of the step. …
eriklharper Aug 30, 2023
e6c977c
fix/refactor of previous issue to ensure that the newly nudged value …
eriklharper Aug 30, 2023
3a7e8ff
adding new usage file and updating existing one
eriklharper Aug 30, 2023
22a3117
math util comment cleanup
eriklharper Aug 30, 2023
7899bb6
typing localeConfig with ILocale. Dayjs's updateLocale doesn't type …
eriklharper Aug 30, 2023
a738c32
Merge branch 'main' into eriklharper/time-milliseconds-support
eriklharper Aug 30, 2023
32d7a72
alphabetizing private prop
eriklharper Aug 30, 2023
b0f0a28
alphabetize #2
eriklharper Aug 30, 2023
6c57565
casing dayjs names consistently
eriklharper Aug 30, 2023
171a709
Merge branch 'main' into eriklharper/time-milliseconds-support
eriklharper Aug 30, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
} from "../../utils/focusTrapComponent";
import { FocusTrap } from "focus-trap";
import {
formatTimePart,
formatTimeString,
isValidTime,
localizeTimeString,
Expand All @@ -68,6 +69,7 @@ import localizedFormat from "dayjs/esm/plugin/localizedFormat";
import preParsePostFormat from "dayjs/esm/plugin/preParsePostFormat";
import updateLocale from "dayjs/esm/plugin/updateLocale";
import { getSupportedLocale } from "../../utils/locale";
import { decimalPlaces } from "../../utils/math";

// some bundlers (e.g., Webpack) need dynamic import paths to be static
const supportedDayJsLocaleToLocaleConfigImport = new Map([
Expand Down Expand Up @@ -129,6 +131,13 @@ dayjs.extend(localizedFormat);
dayjs.extend(preParsePostFormat);
dayjs.extend(updateLocale);

interface DayJSTimeParts {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be done as a follow-up, but can you move this to a new interfaces.d.ts file?

hour: number;
minute: number;
second: number;
millisecond: number;
}

@Component({
tag: "calcite-input-time-picker",
styleUrl: "input-time-picker.scss",
Expand Down Expand Up @@ -323,6 +332,8 @@ export class InputTimePicker

private dialogId = `time-picker-dialog--${guid()}`;

private localeConfig;

/** whether the value of the input was changed as a result of user typing or not */
private userChangedValue = false;

Expand Down Expand Up @@ -476,20 +487,107 @@ export class InputTimePicker
// we need to set the corresponding locale before parsing, otherwise it defaults to English (possible dayjs bug)
dayjs.locale(this.effectiveLocale.toLowerCase());

const dayjsParseResult = dayjs(value, ["LTS", "LT"]);
const nonFractionalSecondParts = this.delocalizeTimeStringToParts(value);

if (dayjsParseResult.isValid()) {
let unformattedTimeString = `${dayjsParseResult.get("hour")}:${dayjsParseResult.get(
"minute"
)}`;
let delocalizedTimeString;

if (this.shouldIncludeFractionalSeconds()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For readability, do you think you could flip this if/else, so that the shortest block comes up first and is quicker to spot?

const stepPrecision = decimalPlaces(this.step);
const centisecondParts = this.delocalizeTimeStringToParts(value, "S");

if (this.shouldIncludeSeconds()) {
unformattedTimeString += `:${dayjsParseResult.get("seconds") || 0}`;
if (stepPrecision === 1) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you look at simplifying this a bit? There does seem to be a bit of repetition between statement blocks. This can be tackled separately in a refactor PR.

if (centisecondParts.millisecond !== 0) {
delocalizedTimeString = this.getTimeStringFromParts(centisecondParts);
} else {
delocalizedTimeString = this.getTimeStringFromParts(nonFractionalSecondParts);
}
} else {
const decisecondParts = this.delocalizeTimeStringToParts(value, "SS");

if (stepPrecision === 2) {
if (decisecondParts.millisecond !== 0) {
delocalizedTimeString = this.getTimeStringFromParts(decisecondParts);
} else if (centisecondParts.millisecond !== 0) {
delocalizedTimeString = this.getTimeStringFromParts(centisecondParts);
} else {
delocalizedTimeString = this.getTimeStringFromParts(nonFractionalSecondParts);
}
} else if (stepPrecision >= 3) {
const millisecondParts = this.delocalizeTimeStringToParts(value, "SSS");

if (millisecondParts.millisecond !== 0) {
delocalizedTimeString = this.getTimeStringFromParts(millisecondParts);
} else if (decisecondParts.millisecond !== 0) {
delocalizedTimeString = this.getTimeStringFromParts(decisecondParts);
} else if (centisecondParts.millisecond !== 0) {
delocalizedTimeString = this.getTimeStringFromParts(centisecondParts);
} else {
delocalizedTimeString = this.getTimeStringFromParts(nonFractionalSecondParts);
}
}
}
} else {
delocalizedTimeString = this.getTimeStringFromParts(nonFractionalSecondParts);
}

return delocalizedTimeString;
}

private delocalizeTimeStringToParts(
localizedTimeString: string,
fractionalSecondFormatToken?: "S" | "SS" | "SSS"
): DayJSTimeParts {
const ltsFormatString = this.localeConfig?.formats?.LTS;
const fractionalSecondTokenMatch = ltsFormatString.match(/ss\.*(S+)/g);

if (fractionalSecondFormatToken && this.shouldIncludeFractionalSeconds()) {
const secondFormatToken = `ss.${fractionalSecondFormatToken}`;
this.localeConfig.formats.LTS = fractionalSecondTokenMatch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion to DRY things a bit:

this.localeConfig.formats.LTS = ltsFormatString.replace(
  fractionalSecondTokenMatch ? fractionalSecondTokenMatch[0] : "ss", 
  secondFormatToken
);

Applies to similar structures where the left side of the assignment is duplicated.

? ltsFormatString.replace(fractionalSecondTokenMatch[0], secondFormatToken)
: ltsFormatString.replace("ss", secondFormatToken);
} else if (fractionalSecondTokenMatch) {
this.localeConfig.formats.LTS = ltsFormatString.replace(fractionalSecondTokenMatch[0], "ss");
}

dayjs.updateLocale(
this.getSupportedDayJSLocale(getSupportedLocale(this.effectiveLocale)),
this.localeConfig
);

const dayjsParseResult = dayjs(localizedTimeString, ["LTS", "LT"]);

if (dayjsParseResult.isValid()) {
return {
hour: dayjsParseResult.get("hour"),
minute: dayjsParseResult.get("minute"),
second: dayjsParseResult.get("second"),
millisecond: dayjsParseResult.get("millisecond"),
};
}
return {
hour: null,
minute: null,
second: null,
millisecond: null,
};
}

return formatTimeString(unformattedTimeString) || "";
private getTimeStringFromParts(parts: DayJSTimeParts): string {
let timeString = "";
if (!parts) {
return timeString;
}
return "";
if (parts.hour !== null && parts.minute !== null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For readability, there might be a way to reduce if nesting by returning early or restructuring this a bit.

Applies to similar structures.

timeString = `${formatTimePart(parts.hour)}:${formatTimePart(parts.minute)}`;
if (this.shouldIncludeSeconds() && parts.second !== null) {
timeString += `:${formatTimePart(parts.second)}`;
if (this.shouldIncludeFractionalSeconds() && parts.millisecond !== null) {
const second = (parts.millisecond * 0.001).toFixed(decimalPlaces(this.step));
timeString += `.${second.toString().replace("0.", "")}`;
}
}
}
return timeString;
}

private popoverCloseHandler = () => {
Expand Down Expand Up @@ -531,17 +629,19 @@ export class InputTimePicker

const newValue = this.delocalizeTimeString(this.calciteInputEl.value);

this.setValue(newValue);
if (isValidTime(newValue)) {
this.setValue(newValue);

const localizedTimeString = localizeTimeString({
value: this.value,
locale: this.effectiveLocale,
numberingSystem: this.numberingSystem,
includeSeconds: this.shouldIncludeSeconds(),
});
const localizedTimeString = localizeTimeString({
value: this.value,
locale: this.effectiveLocale,
numberingSystem: this.numberingSystem,
includeSeconds: this.shouldIncludeSeconds(),
});

if (newValue && this.calciteInputEl.value !== localizedTimeString) {
this.setInputValue(localizedTimeString);
if (newValue && this.calciteInputEl.value !== localizedTimeString) {
this.setInputValue(localizedTimeString);
}
}
} else if (key === "ArrowDown") {
this.open = true;
Expand All @@ -554,22 +654,29 @@ export class InputTimePicker
}
};

private getSupportedDayJSLocale(locale: string) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be moved to getSupportedLocale with a dayjs context, so it's reusable.

const dayjsLocale = locale.toLowerCase();
if (dayjsLocale === "no") {
return "nb";
}
if (dayjsLocale === "pt-pt") {
return "pt";
}
return dayjsLocale;
}

private async loadDateTimeLocaleData(): Promise<void> {
let supportedLocale = getSupportedLocale(this.effectiveLocale).toLowerCase();

if (supportedLocale === "no") {
supportedLocale = "nb";
}

if (supportedLocale === "pt-pt") {
supportedLocale = "pt";
}
supportedLocale = this.getSupportedDayJSLocale(supportedLocale);

const { default: localeConfig } = await supportedDayJsLocaleToLocaleConfigImport.get(
supportedLocale
)();

dayjs.locale(localeConfig, null, true);
this.localeConfig = localeConfig;

dayjs.locale(this.localeConfig, null, true);
dayjs.updateLocale(supportedLocale, this.getExtendedLocaleConfig(supportedLocale));
}

Expand Down Expand Up @@ -656,6 +763,10 @@ export class InputTimePicker
return this.step < 60;
}

private shouldIncludeFractionalSeconds(): boolean {
return decimalPlaces(this.step) > 0;
}

private setCalcitePopoverEl = (el: HTMLCalcitePopoverElement): void => {
this.popoverEl = el;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"meridiem": "AM/PM",
"meridiemDown": "Decrease AM/PM",
"meridiemUp": "Increase AM/PM",
"fractionalSecond": "Fractional second",
"fractionalSecondDown": "Decrease fractional second",
"fractionalSecondUp": "Increase fractional second",
"minute": "Minute",
"minuteDown": "Decrease minute",
"minuteUp": "Increase minute",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const CSS = {
button: "button",
buttonBottomLeft: "button--bottom-left",
buttonBottomRight: "button--bottom-right",
buttonFractionalSecondDown: "button--fractionalSecond-down",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you update fractionalSecond's casing so it's consistent?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not following. The pattern here is the same as the pattern elsewhere in the file for other items.

buttonFractionalSecondUp: "button--fractionalSecond-up",
buttonHourDown: "button--hour-down",
buttonHourUp: "button--hour-up",
buttonMeridiemDown: "button--meridiem-down",
Expand All @@ -14,6 +16,7 @@ export const CSS = {
buttonTopRight: "button--top-right",
column: "column",
delimiter: "delimiter",
fractionalSecond: "fractionalSecond",
hour: "hour",
input: "input",
meridiem: "meridiem",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { newE2EPage } from "@stencil/core/testing";
import { accessible, defaults, focusable, hidden, renders, t9n } from "../../tests/commonTests";
import { formatTimePart } from "../../utils/time";
import { CSS } from "./resources";
import { getElementXY } from "../../tests/utils";

const letterKeys = [
"a",
Expand Down Expand Up @@ -1091,4 +1092,48 @@ describe("calcite-time-picker", () => {

expect(await page.find(`calcite-time-picker >>> .${CSS.second}`)).toBeNull();
});

describe("fractional second support", () => {
it("upward nudge of empty fractional second sets to 0 for step=0.1", async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also add coverage for the component's value prop?

const page = await newE2EPage();
await page.setContent(`<calcite-time-picker step="0.1"></calcite-time-picker>`);
const [buttonUpLocationX, buttonUpLocationY] = await getElementXY(
page,
"calcite-time-picker",
".button--fractionalSecond-up"
);
await page.mouse.click(buttonUpLocationX, buttonUpLocationY);
await page.waitForChanges();
const fractionalSecondEl = await page.find(`calcite-time-picker >>> .input.fractionalSecond`);
expect(fractionalSecondEl.innerHTML).toEqual("0");
});

it("upward nudge of empty fractional second sets to 00 for step=0.01", async () => {
const page = await newE2EPage();
await page.setContent(`<calcite-time-picker step="0.01"></calcite-time-picker>`);
const [buttonUpLocationX, buttonUpLocationY] = await getElementXY(
page,
"calcite-time-picker",
".button--fractionalSecond-up"
);
await page.mouse.click(buttonUpLocationX, buttonUpLocationY);
await page.waitForChanges();
const fractionalSecondEl = await page.find(`calcite-time-picker >>> .input.fractionalSecond`);
expect(fractionalSecondEl.innerHTML).toEqual("00");
});

it("upward nudge of empty fractional second sets to 000 for step=0.001", async () => {
const page = await newE2EPage();
await page.setContent(`<calcite-time-picker step="0.001"></calcite-time-picker>`);
const [buttonUpLocationX, buttonUpLocationY] = await getElementXY(
page,
"calcite-time-picker",
".button--fractionalSecond-up"
);
await page.mouse.click(buttonUpLocationX, buttonUpLocationY);
await page.waitForChanges();
const fractionalSecondEl = await page.find(`calcite-time-picker >>> .input.fractionalSecond`);
expect(fractionalSecondEl.innerHTML).toEqual("000");
});
});
});
Loading