From ea7262e64ffe77266cf93b1f066e938fcdb8d3fd Mon Sep 17 00:00:00 2001 From: Devin Villarosa <102188207+devinvillarosa@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:10:11 -0800 Subject: [PATCH] [UI v2] feat: Adds timezone component (#17101) --- .../components/ui/timezone-select/index.ts | 1 + .../timezone-select.stories.tsx | 17 +++ .../timezone-select/timezone-select.test.tsx | 27 ++++ .../ui/timezone-select/timezone-select.tsx | 133 ++++++++++++++++++ ui-v2/tsconfig.app.json | 4 +- 5 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 ui-v2/src/components/ui/timezone-select/index.ts create mode 100644 ui-v2/src/components/ui/timezone-select/timezone-select.stories.tsx create mode 100644 ui-v2/src/components/ui/timezone-select/timezone-select.test.tsx create mode 100644 ui-v2/src/components/ui/timezone-select/timezone-select.tsx diff --git a/ui-v2/src/components/ui/timezone-select/index.ts b/ui-v2/src/components/ui/timezone-select/index.ts new file mode 100644 index 000000000000..b30100c623da --- /dev/null +++ b/ui-v2/src/components/ui/timezone-select/index.ts @@ -0,0 +1 @@ +export { TimezoneSelect } from "./timezone-select"; diff --git a/ui-v2/src/components/ui/timezone-select/timezone-select.stories.tsx b/ui-v2/src/components/ui/timezone-select/timezone-select.stories.tsx new file mode 100644 index 000000000000..bd70cacf89ed --- /dev/null +++ b/ui-v2/src/components/ui/timezone-select/timezone-select.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { useState } from "react"; +import { TimezoneSelect } from "./timezone-select"; + +const meta: Meta = { + title: "UI/TimezoneSelect", + component: () => , +}; +export default meta; + +export const story: StoryObj = { name: "TimezoneSelect" }; + +const TimezoneSelectStory = () => { + const [value, setValue] = useState("America/Los_Angeles"); + return ; +}; diff --git a/ui-v2/src/components/ui/timezone-select/timezone-select.test.tsx b/ui-v2/src/components/ui/timezone-select/timezone-select.test.tsx new file mode 100644 index 000000000000..2f2775f33601 --- /dev/null +++ b/ui-v2/src/components/ui/timezone-select/timezone-select.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { mockPointerEvents } from "@tests/utils/browser"; +import { expect, test, vi } from "vitest"; + +import { TimezoneSelect } from "./timezone-select"; + +test("TimezoneSelect can select an option", async () => { + mockPointerEvents(); + const user = userEvent.setup(); + + // ------------ Setup + const mockOnSelectFn = vi.fn(); + + render(); + + // ------------ Act + await user.click(screen.getByRole("combobox", { name: /select timezone/i })); + + expect(screen.getByText(/suggested timezones/i)).toBeVisible(); + expect(screen.getByText(/all timezones/i)).toBeVisible(); + + await user.click(screen.getByRole("option", { name: /africa \/ abidjan/i })); + + // ------------ Assert + expect(mockOnSelectFn).toBeCalledWith("Africa/Abidjan"); +}); diff --git a/ui-v2/src/components/ui/timezone-select/timezone-select.tsx b/ui-v2/src/components/ui/timezone-select/timezone-select.tsx new file mode 100644 index 000000000000..c6c4cac8a65f --- /dev/null +++ b/ui-v2/src/components/ui/timezone-select/timezone-select.tsx @@ -0,0 +1,133 @@ +import { + Combobox, + ComboboxCommandEmtpy, + ComboboxCommandGroup, + ComboboxCommandInput, + ComboboxCommandItem, + ComboboxCommandList, + ComboboxContent, + ComboboxTrigger, +} from "@/components/ui/combobox"; +import { + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { useDeferredValue, useMemo, useState } from "react"; + +function getTimezoneLabel(value: string): string { + return value.replaceAll("/", " / ").replaceAll("_", " "); +} + +const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + +const SUGGESTED_TIMEZONES = [ + { label: "UTC", value: "Etc/UTC" }, + { + label: getTimezoneLabel(localTimezone), + value: localTimezone, + }, +]; + +const TIMEZONES = Intl.supportedValuesOf("timeZone") + .map((timezone) => ({ + label: getTimezoneLabel(timezone), + value: timezone, + })) + .slice(0, 5); + +const ALL_TIMEZONES = [...SUGGESTED_TIMEZONES, ...TIMEZONES]; + +type TimezoneSelectProps = { + selectedValue: string | undefined; + onSelect: (value: string) => void; +}; + +export const TimezoneSelect = ({ + selectedValue = "", + onSelect, +}: TimezoneSelectProps) => { + const [search, setSearch] = useState(""); + + const deferredSearch = useDeferredValue(search); + + const filteredSuggestedTimezones = useMemo(() => { + return SUGGESTED_TIMEZONES.filter( + (timeZone) => + timeZone.label.toLowerCase().includes(deferredSearch.toLowerCase()) || + timeZone.value.toLowerCase().includes(deferredSearch.toLowerCase()), + ); + }, [deferredSearch]); + + const filteredTimezones = useMemo(() => { + return TIMEZONES.filter( + (timeZone) => + timeZone.label.toLowerCase().includes(deferredSearch.toLowerCase()) || + timeZone.value.toLowerCase().includes(deferredSearch.toLowerCase()), + ); + }, [deferredSearch]); + + const selectedTimezone = useMemo( + () => ALL_TIMEZONES.find(({ value }) => value === selectedValue), + [selectedValue], + ); + + return ( + + + {selectedTimezone?.label ?? "Select timezone"} + + + + No timezone found + + + {filteredSuggestedTimezones.length > 0 && ( + Suggested timezones + )} + {filteredSuggestedTimezones.map(({ label, value }) => { + return ( + { + onSelect(value); + setSearch(""); + }} + value={value} + > + {label} + + ); + })} + + {filteredTimezones.length > 0 && ( + All timezones + )} + {filteredTimezones.map(({ label, value }) => { + return ( + { + onSelect(value); + setSearch(""); + }} + value={value} + > + {label} + + ); + })} + + + + + ); +}; diff --git a/ui-v2/tsconfig.app.json b/ui-v2/tsconfig.app.json index f2cb1ee0f02c..ccb3046b98c7 100644 --- a/ui-v2/tsconfig.app.json +++ b/ui-v2/tsconfig.app.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "baseUrl": ".",