Skip to content

Commit

Permalink
[UI v2] feat: Adds timezone component (#17101)
Browse files Browse the repository at this point in the history
  • Loading branch information
devinvillarosa authored Feb 11, 2025
1 parent 039693f commit ea7262e
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 2 deletions.
1 change: 1 addition & 0 deletions ui-v2/src/components/ui/timezone-select/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TimezoneSelect } from "./timezone-select";
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";

import { useState } from "react";
import { TimezoneSelect } from "./timezone-select";

const meta: Meta<typeof TimezoneSelect> = {
title: "UI/TimezoneSelect",
component: () => <TimezoneSelectStory />,
};
export default meta;

export const story: StoryObj = { name: "TimezoneSelect" };

const TimezoneSelectStory = () => {
const [value, setValue] = useState("America/Los_Angeles");
return <TimezoneSelect selectedValue={value} onSelect={setValue} />;
};
27 changes: 27 additions & 0 deletions ui-v2/src/components/ui/timezone-select/timezone-select.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TimezoneSelect onSelect={mockOnSelectFn} selectedValue="" />);

// ------------ 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");
});
133 changes: 133 additions & 0 deletions ui-v2/src/components/ui/timezone-select/timezone-select.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Combobox>
<ComboboxTrigger
selected={Boolean(selectedValue)}
aria-label="Select timezone"
>
{selectedTimezone?.label ?? "Select timezone"}
</ComboboxTrigger>
<ComboboxContent>
<ComboboxCommandInput
value={search}
onValueChange={setSearch}
placeholder="Search"
/>
<ComboboxCommandEmtpy>No timezone found</ComboboxCommandEmtpy>
<ComboboxCommandList>
<ComboboxCommandGroup>
{filteredSuggestedTimezones.length > 0 && (
<DropdownMenuLabel>Suggested timezones</DropdownMenuLabel>
)}
{filteredSuggestedTimezones.map(({ label, value }) => {
return (
<ComboboxCommandItem
key={value}
selected={selectedValue === value}
onSelect={(value) => {
onSelect(value);
setSearch("");
}}
value={value}
>
{label}
</ComboboxCommandItem>
);
})}
<DropdownMenuSeparator />
{filteredTimezones.length > 0 && (
<DropdownMenuLabel>All timezones</DropdownMenuLabel>
)}
{filteredTimezones.map(({ label, value }) => {
return (
<ComboboxCommandItem
key={value}
selected={selectedValue === value}
onSelect={(value) => {
onSelect(value);
setSearch("");
}}
value={value}
>
{label}
</ComboboxCommandItem>
);
})}
</ComboboxCommandGroup>
</ComboboxCommandList>
</ComboboxContent>
</Combobox>
);
};
4 changes: 2 additions & 2 deletions ui-v2/tsconfig.app.json
Original file line number Diff line number Diff line change
@@ -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": ".",
Expand Down

0 comments on commit ea7262e

Please sign in to comment.