fix(ui,dashboard): Revamp DatePicker component (#7891)

**What**
- Revamps the DatePicker component. 
- Addresses all issues with broken DatePickers across admin.

**Note**
- Part of this PR is adding a I18nProvider which is used to set the locale that is used for our DatePicker and Calendar components. Per default they use the browser locale. In the current implementation, we are grabbing the locale to use from the language that is picked in the "Profile" section. This means that currently the only possible locale is "en-US", meaning times uses AM/PM. This is likely not what we want, but we need to make a decision on how we want to handle this globally, will create a ticket for it and we can then clean it up later on.
- This PR does not include "presets" or a DateRange picker that were part of the old implementation. Will open tickets to re-add this later on, but since we aren't using it in admin any where it makes sense to address later.
- This PR also bumps and pin every `@radix-ui` dependency in `@medusajs/ui` and `@medusajs/dashboard`. Our different versions were pulling in multiple versions of internal radix dependencies which were breaking Popover and Dialog behaviour across admin. One thing to note is that Radix have started to print warnings for missing Descriptions and Titles in dialogs. We should add these as we go, for better accessibility. Its not an urgent task but something we can add as we clean up admin over the following weeks. 

CLOSES CORE-2382
This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2024-07-02 08:59:32 +00:00
1 parent 074e4a888e
commit a84e5a6ced
47 files changed
+2869 -2419

No files matched your search

+3 -3
View File
@@ -35,14 +35,14 @@
"@medusajs/icons": "1.2.1", "@medusajs/icons": "1.2.1",
"@medusajs/js-sdk": "0.0.1", "@medusajs/js-sdk": "0.0.1",
"@medusajs/ui": "3.0.0", "@medusajs/ui": "3.0.0",
"@radix-ui/react-collapsible": "1.0.3", "@radix-ui/react-collapsible": "1.1.0",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-hover-card": "1.1.1",
"@tanstack/react-query": "^5.28.14", "@tanstack/react-query": "^5.28.14",
"@tanstack/react-table": "8.10.7", "@tanstack/react-table": "8.10.7",
"@tanstack/react-virtual": "^3.0.4", "@tanstack/react-virtual": "^3.0.4",
"@uiw/react-json-view": "^2.0.0-alpha.17", "@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"date-fns": "^3.2.0", "date-fns": "^3.6.0",
"framer-motion": "^11.0.3", "framer-motion": "^11.0.3",
"i18next": "23.7.11", "i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0", "i18next-browser-languagedetector": "7.2.0",
@@ -3,6 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query"
import { I18n } from "./components/utilities/i18n" import { I18n } from "./components/utilities/i18n"
import { queryClient } from "./lib/query-client" import { queryClient } from "./lib/query-client"
import { I18nProvider } from "./providers/i18n-provider"
import { RouterProvider } from "./providers/router-provider" import { RouterProvider } from "./providers/router-provider"
import { ThemeProvider } from "./providers/theme-provider" import { ThemeProvider } from "./providers/theme-provider"
@@ -14,7 +15,9 @@ function App() {
<ThemeProvider> <ThemeProvider>
<I18n /> <I18n />
<TooltipProvider> <TooltipProvider>
<I18nProvider>
<RouterProvider /> <RouterProvider />
</I18nProvider>
</TooltipProvider> </TooltipProvider>
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>
@@ -27,7 +27,7 @@ export const DateRangeDisplay = ({
<Text weight="plus" size="small"> <Text weight="plus" size="small">
{t("fields.startDate")} {t("fields.startDate")}
</Text> </Text>
<Text size="small"> <Text size="small" className="tabular-nums">
{startDate {startDate
? getFullDate({ ? getFullDate({
date: startDate, date: startDate,
@@ -44,7 +44,7 @@ export const DateRangeDisplay = ({
<Text size="small" weight="plus"> <Text size="small" weight="plus">
{t("fields.endDate")} {t("fields.endDate")}
</Text> </Text>
<Text size="small"> <Text size="small" className="tabular-nums">
{endDate {endDate
? getFullDate({ ? getFullDate({
date: endDate, date: endDate,
@@ -78,6 +78,7 @@ const useFormField = () => {
id, id,
name: fieldContext.name, name: fieldContext.name,
formItemId: `${id}-form-item`, formItemId: `${id}-form-item`,
formLabelId: `${id}-form-item-label`,
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formErrorMessageId: `${id}-form-item-message`, formErrorMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
@@ -109,12 +110,13 @@ const Label = forwardRef<
icon?: ReactNode icon?: ReactNode
} }
>(({ className, optional = false, tooltip, icon, ...props }, ref) => { >(({ className, optional = false, tooltip, icon, ...props }, ref) => {
const { formItemId } = useFormField() const { formLabelId, formItemId } = useFormField()
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<LabelComponent <LabelComponent
id={formLabelId}
ref={ref} ref={ref}
className={clx(className)} className={clx(className)}
htmlFor={formItemId} htmlFor={formItemId}
@@ -142,8 +144,13 @@ const Control = forwardRef<
React.ElementRef<typeof Slot>, React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot> React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => { >(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formErrorMessageId } = const {
useFormField() error,
formItemId,
formDescriptionId,
formErrorMessageId,
formLabelId,
} = useFormField()
return ( return (
<Slot <Slot
@@ -155,6 +162,7 @@ const Control = forwardRef<
: `${formDescriptionId} ${formErrorMessageId}` : `${formDescriptionId} ${formErrorMessageId}`
} }
aria-invalid={!!error} aria-invalid={!!error}
aria-labelledby={formLabelId}
{...props} {...props}
/> />
) )
@@ -1 +0,0 @@
export * from "./localized-date-picker"
@@ -1,37 +0,0 @@
import { DatePicker } from "@medusajs/ui"
import { ComponentPropsWithoutRef } from "react"
import { useTranslation } from "react-i18next"
import { languages } from "../../../i18n/languages"
type LocalizedDatePickerProps = Omit<
ComponentPropsWithoutRef<typeof DatePicker>,
"translations" | "locale"
>
export const LocalizedDatePicker = ({
mode = "single",
...props
}: LocalizedDatePickerProps) => {
const { i18n, t } = useTranslation()
const locale = languages.find(
(lang) => lang.code === i18n.language
)?.date_locale
const translations = {
cancel: t("actions.cancel"),
apply: t("general.apply"),
end: t("general.end"),
start: t("general.start"),
range: t("general.range"),
}
return (
<DatePicker
mode={mode}
translations={translations}
locale={locale}
{...(props as any)}
/>
)
}
@@ -1 +1 @@
export * from "./stacked-foucs-modal" export * from "./stacked-focus-modal"
@@ -65,10 +65,7 @@ export const DateFilter = ({
const customStartValue = getDateFromComparison(currentDateComparison, "$gte") const customStartValue = getDateFromComparison(currentDateComparison, "$gte")
const customEndValue = getDateFromComparison(currentDateComparison, "$lte") const customEndValue = getDateFromComparison(currentDateComparison, "$lte")
const handleCustomDateChange = ( const handleCustomDateChange = (value: Date | null, pos: "start" | "end") => {
value: Date | undefined,
pos: "start" | "end"
) => {
const key = pos === "start" ? "$gte" : "$lte" const key = pos === "start" ? "$gte" : "$lte"
const dateValue = value ? value.toISOString() : undefined const dateValue = value ? value.toISOString() : undefined
@@ -201,8 +198,7 @@ export const DateFilter = ({
</div> </div>
<div className="px-2 py-1"> <div className="px-2 py-1">
<DatePicker <DatePicker
// placeholder="MM/DD/YYYY" TODO: Fix DatePicker component not working with placeholder maxValue={customEndValue}
toDate={customEndValue}
value={customStartValue} value={customStartValue}
onChange={(d) => handleCustomDateChange(d, "start")} onChange={(d) => handleCustomDateChange(d, "start")}
/> />
@@ -216,8 +212,7 @@ export const DateFilter = ({
</div> </div>
<div className="px-2 py-1"> <div className="px-2 py-1">
<DatePicker <DatePicker
// placeholder="MM/DD/YYYY" minValue={customStartValue}
fromDate={customStartValue}
value={customEndValue || undefined} value={customEndValue || undefined}
onChange={(d) => { onChange={(d) => {
handleCustomDateChange(d, "end") handleCustomDateChange(d, "end")
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"
import { languages } from "../i18n/languages" import { languages } from "../i18n/languages"
// TODO: We rely on the current language to determine the date locale. This is not ideal, as we use en-US for the english translation.
// We either need to also have an en-GB translation or we need to separate the date locale from the translation language.
export const useDate = () => { export const useDate = () => {
const { i18n } = useTranslation() const { i18n } = useTranslation()
@@ -0,0 +1,16 @@
import { I18nProvider as Provider } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { useTranslation } from "react-i18next"
import { languages } from "../../i18n/languages"
type I18nProviderProps = PropsWithChildren
export const I18nProvider = ({ children }: I18nProviderProps) => {
const { i18n } = useTranslation()
const locale =
languages.find((lan) => lan.code === i18n.language)?.code ||
languages[0].code
return <Provider locale={locale}>{children}</Provider>
}
@@ -0,0 +1 @@
export * from "./i18n-provider"
@@ -5,10 +5,7 @@ import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
import { import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
RouteDrawer,
useRouteModal,
} from "../../../../../components/modals"
import { useUpdateCampaign } from "../../../../../hooks/api/campaigns" import { useUpdateCampaign } from "../../../../../hooks/api/campaigns"
type EditCampaignFormProps = { type EditCampaignFormProps = {
@@ -133,18 +130,16 @@ export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name="starts_at" name="starts_at"
render={({ field: { value, onChange, ref: _ref, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label>{t("campaigns.fields.start_date")}</Form.Label> <Form.Label>{t("campaigns.fields.start_date")}</Form.Label>
<Form.Control> <Form.Control>
<DatePicker <DatePicker
showTimePicker granularity="minute"
value={value ?? undefined} hourCycle={12}
onChange={(v) => { shouldCloseOnSelect={false}
onChange(v ?? null)
}}
{...field} {...field}
/> />
</Form.Control> </Form.Control>
@@ -158,16 +153,15 @@ export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name="ends_at" name="ends_at"
render={({ field: { value, onChange, ref: _ref, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label> <Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
<Form.Control> <Form.Control>
<DatePicker <DatePicker
showTimePicker granularity="minute"
value={value ?? undefined} shouldCloseOnSelect={false}
onChange={(v) => onChange(v ?? null)}
{...field} {...field}
/> />
</Form.Control> </Form.Control>
@@ -138,18 +138,15 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name={`${fieldScope}starts_at`} name={`${fieldScope}starts_at`}
render={({ field: { value, onChange, ref: _ref, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label>{t("campaigns.fields.start_date")}</Form.Label> <Form.Label>{t("campaigns.fields.start_date")}</Form.Label>
<Form.Control> <Form.Control>
<DatePicker <DatePicker
showTimePicker granularity="minute"
value={value ?? undefined} shouldCloseOnSelect={false}
onChange={(v) => {
onChange(v ?? null)
}}
{...field} {...field}
/> />
</Form.Control> </Form.Control>
@@ -163,16 +160,15 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name={`${fieldScope}ends_at`} name={`${fieldScope}ends_at`}
render={({ field: { value, onChange, ref: _ref, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label> <Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
<Form.Control> <Form.Control>
<DatePicker <DatePicker
showTimePicker granularity="minute"
value={value ?? undefined} shouldCloseOnSelect={false}
onChange={(v) => onChange(v ?? null)}
{...field} {...field}
/> />
</Form.Control> </Form.Control>
@@ -41,7 +41,6 @@ const PriceListConfigurationSchema = z.object({
const STACKED_MODAL_ID = "cg" const STACKED_MODAL_ID = "cg"
// TODO: Fix DatePickers once new version is merged.
export const PriceListConfigurationForm = ({ export const PriceListConfigurationForm = ({
priceList, priceList,
customerGroups, customerGroups,
@@ -122,7 +121,7 @@ export const PriceListConfigurationForm = ({
<Form.Field <Form.Field
control={form.control} control={form.control}
name="starts_at" name="starts_at"
render={({ field: { value, onChange, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
@@ -135,12 +134,10 @@ export const PriceListConfigurationForm = ({
</Form.Hint> </Form.Hint>
</div> </div>
<Form.Control> <Form.Control>
{/* TODO: Add timepicker see CORE-2382 */}
<DatePicker <DatePicker
mode="single" granularity="minute"
shouldCloseOnSelect={false}
{...field} {...field}
onChange={(value) => onChange(value ?? null)}
value={value ?? undefined}
/> />
</Form.Control> </Form.Control>
</div> </div>
@@ -153,7 +150,7 @@ export const PriceListConfigurationForm = ({
<Form.Field <Form.Field
control={form.control} control={form.control}
name="ends_at" name="ends_at"
render={({ field: { value, onChange, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
@@ -167,10 +164,9 @@ export const PriceListConfigurationForm = ({
</div> </div>
<Form.Control> <Form.Control>
<DatePicker <DatePicker
mode="single" granularity="minute"
shouldCloseOnSelect={false}
{...field} {...field}
onChange={(value) => onChange(value ?? null)}
value={value ?? undefined}
/> />
</Form.Control> </Form.Control>
</div> </div>
@@ -46,7 +46,6 @@ type PriceListCreateFormProps = {
currencies: HttpTypes.AdminStoreCurrency[] currencies: HttpTypes.AdminStoreCurrency[]
} }
// TODO: Fix DatePickers once new version is merged.
export const PriceListCreateForm = ({ export const PriceListCreateForm = ({
regions, regions,
currencies, currencies,
@@ -173,7 +173,7 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name="starts_at" name="starts_at"
render={({ field: { value, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@@ -186,11 +186,10 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
</Form.Hint> </Form.Hint>
</div> </div>
<Form.Control> <Form.Control>
{/* TODO: Add timepicker see CORE-2382 */}
<DatePicker <DatePicker
mode="single" granularity="minute"
shouldCloseOnSelect={false}
{...field} {...field}
value={value ?? undefined}
/> />
</Form.Control> </Form.Control>
</div> </div>
@@ -203,7 +202,7 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name="ends_at" name="ends_at"
render={({ field: { value, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@@ -215,9 +214,9 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
</div> </div>
<Form.Control> <Form.Control>
<DatePicker <DatePicker
mode="single" granularity="minute"
shouldCloseOnSelect={false}
{...field} {...field}
value={value ?? undefined}
/> />
</Form.Control> </Form.Control>
</div> </div>
@@ -34,7 +34,7 @@ export const PriceListDetails = () => {
) )
})} })}
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start"> <div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-3"> <div className="flex flex-1 flex-col gap-y-3">
<PriceListGeneralSection priceList={price_list} /> <PriceListGeneralSection priceList={price_list} />
<PriceListProductSection priceList={price_list} /> <PriceListProductSection priceList={price_list} />
{after.widgets.map((w, i) => { {after.widgets.map((w, i) => {
@@ -48,7 +48,7 @@ export const PriceListDetails = () => {
<JsonViewSection data={price_list} /> <JsonViewSection data={price_list} />
</div> </div>
</div> </div>
<div className="mt-2 flex w-full max-w-[100%] flex-col gap-y-2 xl:mt-0 xl:max-w-[423px]"> <div className="mt-2 flex w-full max-w-[100%] flex-col gap-y-2 xl:mt-0 xl:max-w-[440px]">
{sideBefore.widgets.map((w, i) => { {sideBefore.widgets.map((w, i) => {
return ( return (
<div key={i}> <div key={i}>
+21 -20
View File
@@ -81,34 +81,35 @@
}, },
"dependencies": { "dependencies": {
"@medusajs/icons": "^1.2.1", "@medusajs/icons": "^1.2.1",
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "1.2.0",
"@radix-ui/react-alert-dialog": "1.0.4", "@radix-ui/react-alert-dialog": "1.1.1",
"@radix-ui/react-avatar": "^1.0.3", "@radix-ui/react-avatar": "1.1.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "1.1.1",
"@radix-ui/react-dialog": "1.0.4", "@radix-ui/react-dialog": "1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-dropdown-menu": "2.1.1",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "2.1.0",
"@radix-ui/react-popover": "^1.0.6", "@radix-ui/react-popover": "1.1.1",
"@radix-ui/react-portal": "^1.0.3", "@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "1.2.0",
"@radix-ui/react-scroll-area": "^1.0.4", "@radix-ui/react-scroll-area": "1.1.0",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "2.1.1",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "1.1.0",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "1.1.0",
"@radix-ui/react-tooltip": "^1.0.6", "@radix-ui/react-tooltip": "1.1.2",
"@react-aria/datepicker": "^3.5.0",
"@react-stately/datepicker": "^3.5.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"cva": "1.0.0-beta.1", "cva": "1.0.0-beta.1",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"prism-react-renderer": "^2.0.6", "prism-react-renderer": "^2.0.6",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react-aria": "^3.33.1",
"react-currency-input-field": "^3.6.11", "react-currency-input-field": "^3.6.11",
"react-day-picker": "^8.8.0", "react-stately": "^3.31.1",
"sonner": "^1.4.41", "sonner": "^1.4.41",
"tailwind-merge": "^2.2.1" "tailwind-merge": "^2.2.1",
"upgrade": "^1.1.0",
"yarn": "^1.22.22"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0", "react": "^18.0.0",
@@ -1,79 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Text } from "@/components/text"
import { DateRange } from "react-day-picker"
import { Calendar } from "./calendar"
const Demo = ({ mode, ...args }: Parameters<typeof Calendar>[0]) => {
const [date, setDate] = React.useState<Date | undefined>(new Date())
const [dateRange, setDateRange] = React.useState<DateRange | undefined>(
undefined
)
return (
<div className="flex flex-col items-center gap-y-4">
<Calendar
{...(args as any)}
mode={mode as "single" | "range"}
selected={mode === "single" ? date : dateRange}
onSelect={mode === "single" ? setDate : setDateRange}
/>
{mode === "single" && (
<Text className="text-ui-fg-base">
Selected Date: {date ? date.toDateString() : "None"}
</Text>
)}
{mode === "range" && (
<Text className="text-ui-fg-base">
Selected Range:{" "}
{dateRange
? `${dateRange.from?.toDateString()} – ${
dateRange.to?.toDateString() ?? ""
}`
: "None"}
</Text>
)}
</div>
)
}
const meta: Meta<typeof Calendar> = {
title: "Components/Calendar",
component: Calendar,
render: Demo,
parameters: {
layout: "centered",
},
}
export default meta
type Story = StoryObj<typeof Calendar>
export const Single: Story = {
args: {
mode: "single",
},
}
export const TwoMonthSingle: Story = {
args: {
mode: "single",
numberOfMonths: 2,
},
}
export const Range: Story = {
args: {
mode: "range",
},
}
export const TwoMonthRange: Story = {
args: {
mode: "range",
numberOfMonths: 2,
},
}
@@ -1,174 +0,0 @@
"use client"
import { TriangleLeftMini, TriangleRightMini } from "@medusajs/icons"
import * as React from "react"
import {
DayPicker,
useDayRender,
type DayPickerRangeProps,
type DayPickerSingleProps,
type DayProps,
} from "react-day-picker"
import { clx } from "@/utils/clx"
import { iconButtonVariants } from "../icon-button"
type OmitKeys<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
}
type KeysToOmit = "showWeekNumber" | "captionLayout" | "mode"
type SingleProps = OmitKeys<DayPickerSingleProps, KeysToOmit>
type RangeProps = OmitKeys<DayPickerRangeProps, KeysToOmit>
/**
* @interface
*/
type CalendarProps =
| ({
mode: "single"
} & SingleProps)
| ({
mode?: undefined
} & SingleProps)
| ({
mode: "range"
} & RangeProps)
/**
* This component is based on the [react-date-picker](https://www.npmjs.com/package/react-date-picker) package.
*
* @excludeExternal
*/
const Calendar = ({
/**
* @ignore
*/
className,
/**
* @ignore
*/
classNames,
/**
* The calendar's mode.
*/
mode = "single",
/**
* Whether to show days of previous and next months.
*
* @keep
*/
showOutsideDays = true,
/**
* The locale to use for formatting dates. To change the locale pass a date-fns locale object.
*
* @keep
*/
locale,
...props
}: CalendarProps) => {
return (
<DayPicker
mode={mode}
showOutsideDays={showOutsideDays}
className={clx(className)}
classNames={{
months: "flex flex-col sm:flex-row",
month: "space-y-2 p-3",
caption: "flex justify-center relative items-center h-8",
caption_label:
"txt-compact-small-plus absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center text-ui-fg-base",
nav: "space-x-1 flex items-center bg-ui-bg-base-pressed rounded-md w-full h-full justify-between p-0.5",
nav_button: clx(
iconButtonVariants({ variant: "transparent", size: "small" })
),
nav_button_previous: "!absolute left-0.5",
nav_button_next: "!absolute right-0.5",
table: "w-full border-collapse space-y-1",
head_row: "flex w-full gap-x-2",
head_cell: clx(
"txt-compact-small-plus text-ui-fg-muted m-0 box-border flex h-8 w-8 items-center justify-center p-0"
),
row: "flex w-full mt-2 gap-x-2",
cell: "txt-compact-small-plus relative rounded-md p-0 text-center focus-within:relative",
day: "txt-compact-small-plus text-ui-fg-base bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:shadow-borders-interactive-with-focus h-8 w-8 rounded-md p-0 text-center outline-none transition-all",
day_selected:
"bg-ui-bg-interactive text-ui-fg-on-color hover:bg-ui-bg-interactive focus-visible:bg-ui-bg-interactive",
day_outside: "text-ui-fg-disabled aria-selected:text-ui-fg-on-color",
day_disabled: "text-ui-fg-disabled",
day_range_middle:
"aria-selected:!bg-ui-bg-highlight aria-selected:!text-ui-fg-interactive",
day_hidden: "invisible",
...classNames,
}}
locale={locale}
components={{
IconLeft: () => <TriangleLeftMini />,
IconRight: () => <TriangleRightMini />,
Day: Day,
}}
{...(props as SingleProps & RangeProps)}
/>
)
}
Calendar.displayName = "Calendar"
const Day = ({ date, displayMonth }: DayProps) => {
const ref = React.useRef<HTMLButtonElement>(null)
const { activeModifiers, buttonProps, divProps, isButton, isHidden } =
useDayRender(date, displayMonth, ref)
const { selected, today, disabled, range_middle } = activeModifiers
React.useEffect(() => {
if (selected) {
ref.current?.focus()
}
}, [selected])
if (isHidden) {
return <></>
}
if (!isButton) {
return (
<div
{...divProps}
className={clx("flex items-center justify-center", divProps.className)}
/>
)
}
const {
children: buttonChildren,
className: buttonClassName,
...buttonPropsRest
} = buttonProps
return (
<button
ref={ref}
{...buttonPropsRest}
type="button"
className={clx("relative", buttonClassName)}
>
{buttonChildren}
{today && (
<span
className={clx(
"absolute right-[5px] top-[5px] h-1 w-1 rounded-full",
{
"bg-ui-fg-interactive": !selected,
"bg-ui-fg-on-color": selected,
"!bg-ui-fg-interactive": selected && range_middle,
"bg-ui-fg-disabled": disabled,
}
)}
/>
)}
</button>
)
}
export { Calendar }
@@ -1 +0,0 @@
export * from "./calendar"
@@ -0,0 +1,53 @@
"use client"
import { createCalendar } from "@internationalized/date"
import { TriangleLeftMini, TriangleRightMini } from "@medusajs/icons"
import * as React from "react"
import {
DateValue,
useCalendar,
useLocale,
type CalendarProps,
} from "react-aria"
import { useCalendarState } from "react-stately"
import { CalendarButton } from "./calendar-button"
import { CalendarGrid } from "./calendar-grid"
/**
* InternalCalendar is the internal implementation of the Calendar component.
* It's not for public use, but only used for other components like DatePicker.
*/
const InternalCalendar = <TDateValue extends DateValue>(
props: CalendarProps<TDateValue>
) => {
const { locale } = useLocale()
const state = useCalendarState({
...props,
locale,
createCalendar,
})
const { calendarProps, prevButtonProps, nextButtonProps, title } =
useCalendar(props, state)
return (
<div {...calendarProps} className="flex flex-col gap-y-2">
<div className="bg-ui-bg-field border-base grid grid-cols-[28px_1fr_28px] items-center gap-1 rounded-md border p-0.5">
<CalendarButton {...prevButtonProps}>
<TriangleLeftMini />
</CalendarButton>
<div className="flex items-center justify-center">
<h2 className="txt-compact-small-plus">{title}</h2>
</div>
<CalendarButton {...nextButtonProps}>
<TriangleRightMini />
</CalendarButton>
</div>
<CalendarGrid state={state} />
</div>
)
}
export { InternalCalendar }
@@ -0,0 +1,29 @@
import * as React from "react"
import { AriaButtonProps, useButton } from "react-aria"
import { IconButton } from "@/components/icon-button"
interface CalendarButtonProps extends AriaButtonProps<"button"> {}
const CalendarButton = React.forwardRef<HTMLButtonElement, CalendarButtonProps>(
({ children, ...props }, ref) => {
const innerRef = React.useRef<HTMLButtonElement>(null)
React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement)
const { buttonProps } = useButton(props, innerRef)
return (
<IconButton
size="small"
variant="transparent"
className="rounded-[4px]"
{...buttonProps}
>
{children}
</IconButton>
)
}
)
CalendarButton.displayName = "CalendarButton"
export { CalendarButton }
@@ -0,0 +1,75 @@
"use client"
import { CalendarDate } from "@internationalized/date"
import * as React from "react"
import { useCalendarCell } from "react-aria"
import { CalendarState } from "react-stately"
import { clx } from "@/utils/clx"
interface CalendarCellProps {
date: CalendarDate
state: CalendarState
}
const CalendarCell = ({ state, date }: CalendarCellProps) => {
const ref = React.useRef(null)
const {
cellProps,
buttonProps,
isSelected,
isOutsideVisibleRange,
isDisabled,
isUnavailable,
formattedDate,
} = useCalendarCell({ date }, state, ref)
const isToday = getIsToday(date)
return (
<td {...cellProps} className="p-1">
<div
{...buttonProps}
ref={ref}
hidden={isOutsideVisibleRange}
className={clx(
"bg-ui-bg-base txt-compact-small relative flex size-8 items-center justify-center rounded-md outline-none transition-fg border border-transparent",
"hover:bg-ui-bg-base-hover",
"focus-visible:shadow-borders-focus focus-visible:border-ui-border-interactive",
{
"!bg-ui-bg-interactive !text-ui-fg-on-color": isSelected,
"hidden": isOutsideVisibleRange,
"text-ui-fg-muted hover:!bg-ui-bg-base cursor-default": isDisabled || isUnavailable,
}
)}
>
{formattedDate}
{isToday && (
<div
role="none"
className={clx(
"bg-ui-bg-interactive absolute bottom-[3px] left-1/2 size-[3px] -translate-x-1/2 rounded-full transition-fg",
{
"bg-ui-fg-on-color": isSelected,
}
)}
/>
)}
</div>
</td>
)
}
/**
* Check if the date is today. The CalendarDate is using a 1-based index for the month.
* @returns Whether the CalendarDate is today.
*/
function getIsToday(date: CalendarDate) {
const today = new Date()
return (
[date.year, date.month, date.day].join("-") ===
[today.getFullYear(), today.getMonth() + 1, today.getDate()].join("-")
)
}
export { CalendarCell }
@@ -0,0 +1,46 @@
import { getWeeksInMonth } from "@internationalized/date"
import * as React from "react"
import { AriaCalendarGridProps, useCalendarGrid, useLocale } from "react-aria"
import { CalendarState } from "react-stately"
import { CalendarCell } from "./calendar-cell"
interface CalendarGridProps extends AriaCalendarGridProps {
state: CalendarState
}
const CalendarGrid = ({ state, ...props }: CalendarGridProps) => {
const { locale } = useLocale()
const { gridProps, headerProps, weekDays } = useCalendarGrid(props, state)
const weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale)
return (
<table {...gridProps}>
<thead {...headerProps}>
<tr>
{weekDays.map((day, index) => (
<th key={index} className="txt-compact-small-plus text-ui-fg-muted size-8 p-1 rounded-md">{day}</th>
))}
</tr>
</thead>
<tbody>
{[...new Array(weeksInMonth).keys()].map((weekIndex) => (
<tr key={weekIndex}>
{state
.getDatesInWeek(weekIndex)
.map((date, i) =>
date ? (
<CalendarCell key={i} state={state} date={date} />
) : (
<td key={i} />
)
)}
</tr>
))}
</tbody>
</table>
)
}
export { CalendarGrid }
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { ComponentPropsWithoutRef } from "react"
import { Button } from "../button"
import { Calendar } from "./calendar"
const meta: Meta<typeof Calendar> = {
title: "Components/CalendarNew",
component: Calendar,
parameters: {
layout: "centered",
},
}
export default meta
type Story = StoryObj<typeof Calendar>
export const Default: Story = {
args: {},
}
const ControlledDemo = (args: ComponentPropsWithoutRef<typeof Calendar>) => {
const [date, setDate] = React.useState<Date | null>(new Date())
return (
<div>
<Calendar {...args} value={date} onChange={setDate} />
<div className="flex items-center justify-between">
<pre className="font-mono txt-compact-small">{date ? date.toDateString() : "null"}</pre>
<Button variant="secondary" size="small" onClick={() => setDate(null)}>Reset</Button>
</div>
</div>
)
}
export const Controlled: Story = {
render: ControlledDemo,
}
export const MinValue: Story = {
args: {
minValue: new Date(),
},
}
export const MaxValue: Story = {
args: {
maxValue: new Date(),
},
}
export const DisabledDates: Story = {
args: {
isDateUnavailable: (date: Date) => {
const unavailable = date.getDay() === 0
return unavailable
},
},
}
@@ -0,0 +1,110 @@
import {
CalendarDate,
createCalendar,
getLocalTimeZone
} from "@internationalized/date"
import { TriangleLeftMini, TriangleRightMini } from "@medusajs/icons"
import * as React from "react"
import {
DateValue,
useCalendar,
useLocale,
type CalendarProps as BaseCalendarProps,
} from "react-aria"
import { useCalendarState } from "react-stately"
import { createCalendarDate, getDefaultCalendarDate, updateCalendarDate } from "@/utils/calendar"
import { CalendarButton } from "./calendar-button"
import { CalendarGrid } from "./calendar-grid"
interface CalendarValueProps {
value?: Date | null
defaultValue?: Date | null
onChange?: (value: Date | null) => void
isDateUnavailable?: (date: Date) => boolean
minValue?: Date
maxValue?: Date
}
interface CalendarProps
extends Omit<BaseCalendarProps<CalendarDate>, keyof CalendarValueProps>,
CalendarValueProps {}
const Calendar = (props: CalendarProps) => {
const [value, setValue] = React.useState<CalendarDate | null | undefined>(
() => getDefaultCalendarDate(props.value, props.defaultValue)
)
const { locale } = useLocale()
const _props = React.useMemo(() => convertProps(props, setValue), [props])
const state = useCalendarState({
..._props,
value,
locale,
createCalendar,
})
React.useEffect(() => {
setValue(props.value ? updateCalendarDate(value, props.value) : null)
}, [props.value])
const { calendarProps, prevButtonProps, nextButtonProps, title } =
useCalendar({ value, ..._props }, state)
return (
<div {...calendarProps} className="flex flex-col gap-y-2">
<div className="bg-ui-bg-field border-base grid grid-cols-[28px_1fr_28px] items-center gap-1 rounded-md border p-0.5">
<CalendarButton {...prevButtonProps}>
<TriangleLeftMini />
</CalendarButton>
<div className="flex items-center justify-center">
<h2 className="txt-compact-small-plus">{title}</h2>
</div>
<CalendarButton {...nextButtonProps}>
<TriangleRightMini />
</CalendarButton>
</div>
<CalendarGrid state={state} />
</div>
)
}
function convertProps(
props: CalendarProps,
setValue: React.Dispatch<
React.SetStateAction<CalendarDate | null | undefined>
>
): BaseCalendarProps<CalendarDate> {
const {
minValue,
maxValue,
isDateUnavailable: _isDateUnavailable,
onChange: _onChange,
value: __value__,
defaultValue: __defaultValue__,
...rest
} = props
const onChange = (value: CalendarDate | null) => {
setValue(value)
_onChange?.(value ? value.toDate(getLocalTimeZone()) : null)
}
const isDateUnavailable = (date: DateValue) => {
const _date = date.toDate(getLocalTimeZone())
return _isDateUnavailable ? _isDateUnavailable(_date) : false
}
return {
...rest,
onChange,
isDateUnavailable,
minValue: minValue ? createCalendarDate(minValue) : minValue,
maxValue: maxValue ? createCalendarDate(maxValue) : maxValue,
}
}
export { Calendar }
@@ -0,0 +1,2 @@
export * from "./_internal-calendar"
export * from "./calendar"
@@ -0,0 +1,42 @@
"use client"
import { clx } from "@/utils/clx"
import * as React from "react"
import { AriaButtonProps, useButton } from "react-aria"
interface CalendarButtonProps extends AriaButtonProps<"button"> {
size?: "base" | "small"
}
const DatePickerButton = React.forwardRef<
HTMLButtonElement,
CalendarButtonProps
>(({ children, size = "base", ...props }, ref) => {
const innerRef = React.useRef<HTMLButtonElement>(null)
React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement)
const { buttonProps } = useButton(props, innerRef)
return (
<button
type="button"
className={clx(
"text-ui-fg-muted transition-fg flex items-center justify-center border-r outline-none",
"disabled:text-ui-fg-disabled",
"hover:bg-ui-button-transparent-hover",
"focus-visible:bg-ui-bg-interactive focus-visible:text-ui-fg-on-color",
{
"size-7": size === "small",
"size-8": size === "base",
}
)}
aria-label="Open calendar"
{...buttonProps}
>
{children}
</button>
)
})
DatePickerButton.displayName = "DatePickerButton"
export { DatePickerButton }
@@ -0,0 +1,40 @@
"use client"
import { clx } from "@/utils/clx"
import * as React from "react"
const ALLOWED_KEYS = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"]
export const DatePickerClearButton = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<"button">
>(({ type = "button", className, children, ...props }, ref) => {
/**
* Allows the button to be used with only the keyboard.
* Otherwise the wrapping component will hijack the event.
*/
const stopPropagation = (e: React.KeyboardEvent) => {
if (!ALLOWED_KEYS.includes(e.key)) {
e.stopPropagation()
}
}
return (
<button
ref={ref}
type={type}
className={clx(
"text-ui-fg-muted transition-fg flex size-full items-center justify-center outline-none",
"hover:bg-ui-button-transparent-hover",
"focus-visible:bg-ui-bg-interactive focus-visible:text-ui-fg-on-color",
className
)}
aria-label="Clear date"
onKeyDown={stopPropagation}
{...props}
>
{children}
</button>
)
})
DatePickerClearButton.displayName = "DatePickerClearButton"
@@ -0,0 +1,55 @@
"use client"
import { createCalendar } from "@internationalized/date"
import * as React from "react"
import {
AriaDatePickerProps,
DateValue,
useDateField,
useLocale,
} from "react-aria"
import { useDateFieldState } from "react-stately"
import { DateSegment } from "@/components/date-segment"
import { cva } from "cva"
interface DatePickerFieldProps extends AriaDatePickerProps<DateValue> {
size?: "base" | "small"
}
const datePickerFieldStyles = cva({
base: "flex items-center tabular-nums",
variants: {
size: {
small: "py-1",
base: "py-1.5",
},
},
defaultVariants: {
size: "base",
},
})
const DatePickerField = ({ size = "base", ...props }: DatePickerFieldProps) => {
const { locale } = useLocale()
const state = useDateFieldState({
...props,
locale,
createCalendar,
})
const ref = React.useRef<HTMLDivElement>(null)
const { fieldProps } = useDateField(props, state, ref)
return (
<div ref={ref} aria-label="Date input" className={datePickerFieldStyles({ size })} {...fieldProps}>
{state.segments.map((segment, index) => {
return <DateSegment key={index} segment={segment} state={state} />
})}
</div>
)
}
export { DatePickerField }
@@ -1,58 +0,0 @@
import { render, screen } from "@testing-library/react"
import * as React from "react"
import { DatePicker } from "./date-picker"
describe("DatePicker", () => {
describe("Preset validation", () => {
it("should throw an error if a preset is before the min year", async () => {
expect(() =>
render(
<DatePicker
fromYear={1800}
presets={[
{
label: "Year of the first US census",
date: new Date(1790, 0, 1),
},
]}
/>
)
).toThrowError(
/Preset Year of the first US census is before fromYear 1800./
)
})
it("should throw an error if a preset is after the max year", async () => {
expect(() =>
render(
<DatePicker
toYear={2012}
presets={[
{
label: "End of the Mayan calendar",
date: new Date(2025, 0, 1),
},
]}
/>
)
).toThrowError(/Preset End of the Mayan calendar is after toYear 2012./)
})
})
describe("Single", () => {
it("should render", async () => {
render(<DatePicker />)
expect(screen.getByRole("button")).toBeInTheDocument()
})
})
describe("Range", () => {
it("should render", async () => {
render(<DatePicker mode={"range"} />)
expect(screen.getByRole("button")).toBeInTheDocument()
})
})
})
@@ -1,243 +1,137 @@
import type { Meta, StoryObj } from "@storybook/react" import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react" import * as React from "react"
import { DateRange } from "react-day-picker" import { ComponentPropsWithoutRef } from "react"
import { Button } from "../button"
import { Button } from "@/components/button" import { Drawer } from "../drawer"
import { Label } from "../label"
import { DatePicker } from "./date-picker" import { DatePicker } from "./date-picker"
import { Popover } from "@/components/popover"
const meta: Meta<typeof DatePicker> = { const meta: Meta<typeof DatePicker> = {
title: "Components/DatePicker", title: "Components/DatePickerNew",
component: DatePicker, component: DatePicker,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
render: (args) => {
return (
<div className="w-[200px]">
<DatePicker {...args} />
</div>
)
},
} }
export default meta export default meta
type Story = StoryObj<typeof DatePicker> type Story = StoryObj<typeof DatePicker>
const presets = [ export const Default: Story = {
{
label: "Today",
date: new Date(),
},
{
label: "Tomorrow",
date: new Date(new Date().setDate(new Date().getDate() + 1)),
},
{
label: "A week from now",
date: new Date(new Date().setDate(new Date().getDate() + 7)),
},
{
label: "A month from now",
date: new Date(new Date().setMonth(new Date().getMonth() + 1)),
},
{
label: "6 months from now",
date: new Date(new Date().setMonth(new Date().getMonth() + 6)),
},
{
label: "A year from now",
date: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
]
export const Single: Story = {
args: {},
}
export const SingleWithPresets: Story = {
args: { args: {
presets, "aria-label": "Select a date",
}, },
} }
export const SingleWithTimePicker: Story = { const today = new Date() // Today
args: { const oneWeekFromToday = new Date(new Date(today as Date).setDate(today.getDate() + 7)) // Today + 7 days
showTimePicker: true,
},
}
export const SingleWithTimePickerAndPresets: Story = { const ControlledDemo = (args: ComponentPropsWithoutRef<typeof DatePicker>) => {
args: { const [startDate, setStartDate] = React.useState<Date | null>(today)
showTimePicker: true, const [endDate, setEndDate] = React.useState<Date | null>(oneWeekFromToday)
presets,
},
}
const rangePresets = [
{
label: "Today",
dateRange: {
from: new Date(),
to: new Date(),
},
},
{
label: "Last 7 days",
dateRange: {
from: new Date(new Date().setDate(new Date().getDate() - 7)),
to: new Date(),
},
},
{
label: "Last 30 days",
dateRange: {
from: new Date(new Date().setDate(new Date().getDate() - 30)),
to: new Date(),
},
},
{
label: "Last 3 months",
dateRange: {
from: new Date(new Date().setMonth(new Date().getMonth() - 3)),
to: new Date(),
},
},
{
label: "Last 6 months",
dateRange: {
from: new Date(new Date().setMonth(new Date().getMonth() - 6)),
to: new Date(),
},
},
{
label: "Month to date",
dateRange: {
from: new Date(new Date().setDate(1)),
to: new Date(),
},
},
{
label: "Year to date",
dateRange: {
from: new Date(new Date().setFullYear(new Date().getFullYear(), 0, 1)),
to: new Date(),
},
},
]
export const Range: Story = {
args: {
mode: "range",
},
}
export const RangeWithPresets: Story = {
args: {
mode: "range",
presets: rangePresets,
},
}
export const RangeWithTimePicker: Story = {
args: {
mode: "range",
showTimePicker: true,
},
}
export const RangeWithTimePickerAndPresets: Story = {
args: {
mode: "range",
showTimePicker: true,
presets: rangePresets,
},
}
const ControlledDemo = () => {
const [value, setValue] = React.useState<Date | undefined>(undefined)
return ( return (
<div className="flex w-[200px] flex-col gap-y-4"> <div className="text-ui-fg-subtle grid max-w-[576px] gap-4 md:grid-cols-2">
<fieldset className="flex flex-col gap-y-0.5">
<Label id="starts_at:r1:label" htmlFor="starts_at:r1">Starts at</Label>
<DatePicker <DatePicker
value={value} id="starts_at:r1"
onChange={(value) => { aria-labelledby="starts_at:r1:label"
setValue(value) {...args}
}} maxValue={endDate || undefined}
value={startDate}
onChange={setStartDate}
/> />
<Button onClick={() => setValue(undefined)}>Reset</Button> </fieldset>
<fieldset className="flex flex-col gap-y-0.5">
<Label id="ends_at:r1:label" htmlFor="ends_at:r1">Ends at</Label>
<DatePicker
id="ends_at:r1"
name="ends_at"
aria-labelledby="ends_at:r1:label"
minValue={startDate || undefined}
{...args}
value={endDate}
onChange={setEndDate}
/>
</fieldset>
</div> </div>
) )
} }
export const Controlled: Story = { export const Controlled: Story = {
render: () => <ControlledDemo />, render: ControlledDemo,
args: {
className: "w-[230px]",
},
} }
const ControlledRangeDemo = () => { export const MinValue: Story = {
const [value, setValue] = React.useState<DateRange | undefined>(undefined) args: {
"aria-label": "Select a date",
className: "w-[230px]",
minValue: new Date(),
},
}
React.useEffect(() => { export const MaxValue: Story = {
console.log("Value changed: ", value) args: {
}, [value]) "aria-label": "Select a date",
className: "w-[230px]",
maxValue: new Date(),
},
}
export const DisabledDates: Story = {
args: {
isDateUnavailable: (date: Date) => {
const unavailable = date.getDay() === 0
return unavailable
},
"aria-label": "Select a date",
className: "w-[230px]",
},
}
export const WithTime: Story = {
args: {
granularity: "minute",
"aria-label": "Select a date",
className: "w-[230px]",
value: new Date(),
},
}
export const Small: Story = {
args: {
size: "small",
"aria-label": "Select a date",
className: "w-[230px]",
},
}
const InDrawerDemo = (args: ComponentPropsWithoutRef<typeof DatePicker>) => {
return ( return (
<div className="flex w-[200px] flex-col gap-y-4"> <Drawer>
<DatePicker <Drawer.Trigger asChild>
mode="range" <Button size="small">Open Drawer</Button>
value={value} </Drawer.Trigger>
onChange={(value) => { <Drawer.Content>
setValue(value) <Drawer.Header>
}} <Drawer.Title>Select a date</Drawer.Title>
/> </Drawer.Header>
<Button onClick={() => setValue(undefined)}>Reset</Button> <Drawer.Body>
<div className="p-4">
<DatePicker {...args} />
</div> </div>
</Drawer.Body>
</Drawer.Content>
</Drawer>
) )
} }
export const ControlledRange: Story = { export const InDrawer: Story = {
render: () => <ControlledRangeDemo />, render: InDrawerDemo,
}
type NestedProps = {
value?: Date
onChange?: (value: Date | undefined) => void
}
const Nested = ({ value, onChange }: NestedProps) => {
return (
<Popover>
<Popover.Trigger asChild>
<Button>Open</Button>
</Popover.Trigger>
<Popover.Content>
<div className="px-3 py-2">
<DatePicker value={value} onChange={onChange} />
</div>
<Popover.Seperator />
<div className="px-3 py-2">
<DatePicker value={value} onChange={onChange} />
</div>
<Popover.Seperator />
<div className="flex items-center justify-between gap-x-2 px-3 py-2 [&_button]:w-full">
<Button variant="secondary">Clear</Button>
<Button>Apply</Button>
</div>
</Popover.Content>
</Popover>
)
}
const NestedDemo = () => {
const [value, setValue] = React.useState<Date | undefined>(undefined)
return (
<div className="flex w-[200px] flex-col gap-y-4">
<Nested value={value} onChange={setValue} />
</div>
)
}
export const NestedControlled: Story = {
render: () => <NestedDemo />,
} }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { useDateSegment } from "react-aria"
import { DateFieldState, DateSegment as Segment } from "react-stately"
import { clx } from "@/utils/clx"
interface DateSegmentProps extends React.ComponentPropsWithoutRef<"div"> {
segment: Segment
state: DateFieldState
}
const DateSegment = ({ segment, state }: DateSegmentProps) => {
const ref = React.useRef<HTMLDivElement>(null)
const { segmentProps } = useDateSegment(segment, state, ref)
const isComma = segment.type === "literal" && segment.text === ", "
/**
* We render an empty span with a margin to maintain the correct spacing
* between date and time segments.
*/
if (isComma) {
return <span className="mx-1" />
}
return (
/**
* We wrap the segment in a span to prevent the segment from being
* focused when the user clicks outside of the component.
*
* See: https://github.com/adobe/react-spectrum/issues/3164
*/
<span>
<div
ref={ref}
className={clx(
"transition-fg outline-none",
"focus-visible:bg-ui-bg-interactive focus-visible:text-ui-fg-on-color",
{
"text-ui-fg-muted uppercase": segment.isPlaceholder,
"text-ui-fg-muted": !segment.isEditable && !state.value,
}
)}
{...segmentProps}
>
{segment.text}
</div>
</span>
)
}
export { DateSegment }
@@ -0,0 +1 @@
export * from "./date-segment"
@@ -0,0 +1,12 @@
"use client"
import * as React from "react"
import { I18nProvider as Primitive, I18nProviderProps as Props } from "react-aria"
interface I18nProviderProps extends Props {}
const I18nProvider = (props: I18nProviderProps) => {
return <Primitive {...props} />
}
export { I18nProvider }
@@ -0,0 +1 @@
export * from "./i18n-provider"
@@ -37,7 +37,8 @@ const Close = React.forwardRef<
}) })
Close.displayName = "Popover.Close" Close.displayName = "Popover.Close"
interface ContentProps extends React.ComponentPropsWithoutRef<typeof Primitives.Content> {} interface ContentProps
extends React.ComponentPropsWithoutRef<typeof Primitives.Content> {}
/** /**
* @excludeExternal * @excludeExternal
@@ -56,8 +56,8 @@ Root.displayName = "Prompt"
const Trigger = Primitives.Trigger const Trigger = Primitives.Trigger
Trigger.displayName = "Prompt.Trigger" Trigger.displayName = "Prompt.Trigger"
const Portal = ({ className, ...props }: Primitives.AlertDialogPortalProps) => { const Portal = (props: Primitives.AlertDialogPortalProps) => {
return <Primitives.AlertDialogPortal className={clx(className)} {...props} /> return <Primitives.AlertDialogPortal {...props} />
} }
Portal.displayName = "Prompt.Portal" Portal.displayName = "Prompt.Portal"
@@ -1,56 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { isBrowserLocaleClockType24h } from "../../utils/is-browser-locale-hour-cycle-24h"
import { TimeInput } from "./time-input"
const meta: Meta<typeof TimeInput> = {
title: "Components/TimeInput",
component: TimeInput,
parameters: {
layout: "centered",
},
render: (args) => (
<div className="w-[300px]">
<TimeInput aria-labelledby="time" {...args} />
</div>
),
}
export default meta
type Story = StoryObj<typeof TimeInput>
// Hour Cycle defaults to your browser's locale.
export const Default: Story = {
render: (args) => {
return (
<div className="flex flex-col gap-y-4">
<TimeInput aria-labelledby="time" {...args} />
<div className="flex flex-col gap-y-2">
<p className="text-xs">
Will use 24h or 12h cycle depending on your locale
</p>
<div className="text-xs">
<pre>
Locale: {window.navigator.language}, Hour Cycle:{" "}
{isBrowserLocaleClockType24h() ? "24h" : "12h"}
</pre>
</div>
</div>
</div>
)
},
}
export const Hour24: Story = {
args: {
hourCycle: 24,
},
}
export const Hour12: Story = {
args: {
hourCycle: 12,
},
}
@@ -1,131 +1,44 @@
"use client" "use client"
import * as React from "react"
import { import {
AriaTimeFieldProps, AriaTimeFieldProps,
TimeValue, TimeValue,
useDateSegment, useLocale,
useTimeField, useTimeField,
} from "@react-aria/datepicker" } from "react-aria"
import { import { useTimeFieldState } from "react-stately"
useTimeFieldState,
type DateFieldState,
type DateSegment,
} from "@react-stately/datepicker"
import * as React from "react"
import { inputBaseStyles } from "@/components/input" import { DateSegment } from "@/components/date-segment"
import { clx } from "@/utils/clx" import { clx } from "@/utils/clx"
type TimeSegmentProps = { const TimeInput = (props: AriaTimeFieldProps<TimeValue>) => {
segment: DateSegment
state: DateFieldState
}
const TimeSegment = ({ segment, state }: TimeSegmentProps) => {
const ref = React.useRef<HTMLDivElement>(null) const ref = React.useRef<HTMLDivElement>(null)
const { segmentProps } = useDateSegment(segment, state, ref) const { locale } = useLocale()
const state = useTimeFieldState({
const isColon = segment.type === "literal" && segment.text === ":" ...props,
const isSpace = segment.type === "literal" && segment.text === " " locale,
})
const isDecorator = isColon || isSpace const { fieldProps } = useTimeField(props, state, ref)
return ( return (
<div <div
{...segmentProps}
ref={ref} ref={ref}
{...fieldProps}
aria-label="Time input"
className={clx( className={clx(
"txt-compact-small w-full rounded-md px-2 py-1 text-left uppercase tabular-nums", "bg-ui-bg-field shadow-borders-base txt-compact-small flex items-center rounded-md px-2 py-1",
inputBaseStyles,
"group-aria-[invalid=true]/time-input:!shadow-borders-error group-invalid/time-input:!shadow-borders-error",
{ {
"text-ui-fg-muted !w-fit border-none bg-transparent px-0 shadow-none": "": props.isDisabled,
isDecorator,
hidden: isSpace,
"text-ui-fg-disabled bg-ui-bg-disabled border-ui-border-base shadow-none":
state.isDisabled,
"!text-ui-fg-muted !bg-transparent": !segment.isEditable,
} }
)} )}
> >
<span {state.segments.map((segment, index) => {
aria-hidden="true" return <DateSegment key={index} segment={segment} state={state} />
className={clx( })}
"txt-compact-small text-ui-fg-muted pointer-events-none block w-full text-left",
{
hidden: !segment.isPlaceholder,
"h-0": !segment.isPlaceholder,
}
)}
>
{segment.placeholder}
</span>
{segment.isPlaceholder ? "" : segment.text}
</div> </div>
) )
} }
type TimeInputProps = Omit<
AriaTimeFieldProps<TimeValue>,
"label" | "shouldForceLeadingZeros" | "description" | "errorMessage"
>
/**
* This component is based on the `div` element and supports all of its props.
*/
const TimeInput = React.forwardRef<HTMLDivElement, TimeInputProps>(
(
{
/**
* The time's format. If no value is specified, the format is
* set based on the user's locale.
*/
hourCycle,
...props
}: TimeInputProps,
ref
) => {
const innerRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(
ref,
() => innerRef?.current
)
const locale = window !== undefined ? window.navigator.language : "en-US"
const state = useTimeFieldState({
hourCycle: hourCycle,
locale: locale,
shouldForceLeadingZeros: true,
autoFocus: true,
...props,
})
const { fieldProps } = useTimeField(
{
...props,
hourCycle: hourCycle,
shouldForceLeadingZeros: true,
},
state,
innerRef
)
return (
<div
{...fieldProps}
ref={innerRef}
className="group/time-input inline-flex w-full gap-x-2"
>
{state.segments.map((segment, i) => (
<TimeSegment key={i} segment={segment} state={state} />
))}
</div>
)
}
)
TimeInput.displayName = "TimeInput"
export { TimeInput } export { TimeInput }
+2 -1
View File
@@ -2,7 +2,7 @@ export { Alert } from "./components/alert"
export { Avatar } from "./components/avatar" export { Avatar } from "./components/avatar"
export { Badge } from "./components/badge" export { Badge } from "./components/badge"
export { Button } from "./components/button" export { Button } from "./components/button"
export { Calendar } from "./components/calendar" export { Calendar } from "./components/calender"
export { Checkbox } from "./components/checkbox" export { Checkbox } from "./components/checkbox"
export { Code } from "./components/code" export { Code } from "./components/code"
export { CodeBlock } from "./components/code-block" export { CodeBlock } from "./components/code-block"
@@ -17,6 +17,7 @@ export { DropdownMenu } from "./components/dropdown-menu"
export { FocusModal } from "./components/focus-modal" export { FocusModal } from "./components/focus-modal"
export { Heading } from "./components/heading" export { Heading } from "./components/heading"
export { Hint } from "./components/hint" export { Hint } from "./components/hint"
export { I18nProvider } from "./components/i18n-provider"
export { IconBadge } from "./components/icon-badge" export { IconBadge } from "./components/icon-badge"
export { IconButton } from "./components/icon-button" export { IconButton } from "./components/icon-button"
export { Input } from "./components/input" export { Input } from "./components/input"
+2
View File
@@ -53,3 +53,5 @@ export type ToastAction = {
*/ */
variant?: ToastActionVariant variant?: ToastActionVariant
} }
export type Granularity = "day" | "hour" | "minute" | "second"
@@ -0,0 +1,138 @@
import { CalendarDate, CalendarDateTime } from "@internationalized/date"
import { Granularity } from "@/types"
function getDefaultCalendarDateTime(
value: Date | null | undefined,
defaultValue: Date | null | undefined
) {
if (value) {
return createCalendarDateTime(value)
}
if (defaultValue) {
return createCalendarDateTime(defaultValue)
}
return null
}
function createCalendarDateTime(date: Date) {
return new CalendarDateTime(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds()
)
}
function updateCalendarDateTime(
date: CalendarDateTime | null | undefined,
value: Date
) {
if (!date) {
return createCalendarDateTime(value)
}
date.set({
day: value.getDate(),
month: value.getMonth() + 1,
year: value.getFullYear(),
hour: value.getHours(),
minute: value.getMinutes(),
second: value.getSeconds(),
millisecond: value.getMilliseconds(),
})
return date
}
function getDefaultCalendarDate(
value: Date | null | undefined,
defaultValue: Date | null | undefined
) {
if (value) {
return createCalendarDate(value)
}
if (defaultValue) {
return createCalendarDate(defaultValue)
}
return null
}
function createCalendarDate(date: Date) {
return new CalendarDate(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
)
}
function updateCalendarDate(
date: CalendarDate | null | undefined,
value: Date
) {
if (!date) {
return createCalendarDate(value)
}
date.set({
day: value.getDate(),
month: value.getMonth() + 1,
year: value.getFullYear(),
})
return date
}
const USES_TIME = new Set<Granularity>(["hour", "minute", "second"])
function createCalendarDateFromDate(date: Date, granularity?: Granularity) {
if (granularity && USES_TIME.has(granularity)) {
return createCalendarDateTime(date)
}
return createCalendarDate(date)
}
function updateCalendarDateFromDate(
date: CalendarDate | CalendarDateTime | null | undefined,
value: Date,
granularity?: Granularity
) {
if (granularity && USES_TIME.has(granularity)) {
return updateCalendarDateTime(date as CalendarDateTime, value)
}
return updateCalendarDate(date as CalendarDate, value)
}
function getDefaultCalendarDateFromDate(
value: Date | null | undefined,
defaultValue: Date | null | undefined,
granularity?: Granularity
) {
if (value) {
return createCalendarDateFromDate(value, granularity)
}
if (defaultValue) {
return createCalendarDateFromDate(defaultValue, granularity)
}
return null
}
export {
createCalendarDate,
createCalendarDateFromDate,
getDefaultCalendarDate,
getDefaultCalendarDateFromDate,
updateCalendarDate,
updateCalendarDateFromDate,
}
+1745 -580
View File
File diff suppressed because it is too large. Load diff