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
2024-07-02 10:59:32 +02:00
committed by GitHub
parent 074e4a888e
commit a84e5a6ced
47 changed files with 2874 additions and 2424 deletions

View File

@@ -35,14 +35,14 @@
"@medusajs/icons": "1.2.1",
"@medusajs/js-sdk": "0.0.1",
"@medusajs/ui": "3.0.0",
"@radix-ui/react-collapsible": "1.0.3",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-collapsible": "1.1.0",
"@radix-ui/react-hover-card": "1.1.1",
"@tanstack/react-query": "^5.28.14",
"@tanstack/react-table": "8.10.7",
"@tanstack/react-virtual": "^3.0.4",
"@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0",
"date-fns": "^3.2.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.0.3",
"i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0",

View File

@@ -3,6 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query"
import { I18n } from "./components/utilities/i18n"
import { queryClient } from "./lib/query-client"
import { I18nProvider } from "./providers/i18n-provider"
import { RouterProvider } from "./providers/router-provider"
import { ThemeProvider } from "./providers/theme-provider"
@@ -14,7 +15,9 @@ function App() {
<ThemeProvider>
<I18n />
<TooltipProvider>
<RouterProvider />
<I18nProvider>
<RouterProvider />
</I18nProvider>
</TooltipProvider>
<Toaster />
</ThemeProvider>

View File

@@ -27,7 +27,7 @@ export const DateRangeDisplay = ({
<Text weight="plus" size="small">
{t("fields.startDate")}
</Text>
<Text size="small">
<Text size="small" className="tabular-nums">
{startDate
? getFullDate({
date: startDate,
@@ -44,7 +44,7 @@ export const DateRangeDisplay = ({
<Text size="small" weight="plus">
{t("fields.endDate")}
</Text>
<Text size="small">
<Text size="small" className="tabular-nums">
{endDate
? getFullDate({
date: endDate,

View File

@@ -78,6 +78,7 @@ const useFormField = () => {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formLabelId: `${id}-form-item-label`,
formDescriptionId: `${id}-form-item-description`,
formErrorMessageId: `${id}-form-item-message`,
...fieldState,
@@ -109,12 +110,13 @@ const Label = forwardRef<
icon?: ReactNode
}
>(({ className, optional = false, tooltip, icon, ...props }, ref) => {
const { formItemId } = useFormField()
const { formLabelId, formItemId } = useFormField()
const { t } = useTranslation()
return (
<div className="flex items-center gap-x-1">
<LabelComponent
id={formLabelId}
ref={ref}
className={clx(className)}
htmlFor={formItemId}
@@ -142,8 +144,13 @@ const Control = forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formErrorMessageId } =
useFormField()
const {
error,
formItemId,
formDescriptionId,
formErrorMessageId,
formLabelId,
} = useFormField()
return (
<Slot
@@ -155,6 +162,7 @@ const Control = forwardRef<
: `${formDescriptionId} ${formErrorMessageId}`
}
aria-invalid={!!error}
aria-labelledby={formLabelId}
{...props}
/>
)

View File

@@ -1 +0,0 @@
export * from "./localized-date-picker"

View File

@@ -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)}
/>
)
}

View File

@@ -1 +1 @@
export * from "./stacked-foucs-modal"
export * from "./stacked-focus-modal"

View File

@@ -65,10 +65,7 @@ export const DateFilter = ({
const customStartValue = getDateFromComparison(currentDateComparison, "$gte")
const customEndValue = getDateFromComparison(currentDateComparison, "$lte")
const handleCustomDateChange = (
value: Date | undefined,
pos: "start" | "end"
) => {
const handleCustomDateChange = (value: Date | null, pos: "start" | "end") => {
const key = pos === "start" ? "$gte" : "$lte"
const dateValue = value ? value.toISOString() : undefined
@@ -201,8 +198,7 @@ export const DateFilter = ({
</div>
<div className="px-2 py-1">
<DatePicker
// placeholder="MM/DD/YYYY" TODO: Fix DatePicker component not working with placeholder
toDate={customEndValue}
maxValue={customEndValue}
value={customStartValue}
onChange={(d) => handleCustomDateChange(d, "start")}
/>
@@ -216,8 +212,7 @@ export const DateFilter = ({
</div>
<div className="px-2 py-1">
<DatePicker
// placeholder="MM/DD/YYYY"
fromDate={customStartValue}
minValue={customStartValue}
value={customEndValue || undefined}
onChange={(d) => {
handleCustomDateChange(d, "end")

View File

@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"
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 = () => {
const { i18n } = useTranslation()

View File

@@ -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>
}

View File

@@ -0,0 +1 @@
export * from "./i18n-provider"

View File

@@ -5,10 +5,7 @@ import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/modals"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { useUpdateCampaign } from "../../../../../hooks/api/campaigns"
type EditCampaignFormProps = {
@@ -133,18 +130,16 @@ export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
<Form.Field
control={form.control}
name="starts_at"
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.start_date")}</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
granularity="minute"
hourCycle={12}
shouldCloseOnSelect={false}
{...field}
/>
</Form.Control>
@@ -158,16 +153,15 @@ export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
<Form.Field
control={form.control}
name="ends_at"
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => onChange(v ?? null)}
granularity="minute"
shouldCloseOnSelect={false}
{...field}
/>
</Form.Control>

View File

@@ -138,18 +138,15 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
<Form.Field
control={form.control}
name={`${fieldScope}starts_at`}
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.start_date")}</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
granularity="minute"
shouldCloseOnSelect={false}
{...field}
/>
</Form.Control>
@@ -163,16 +160,15 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
<Form.Field
control={form.control}
name={`${fieldScope}ends_at`}
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => onChange(v ?? null)}
granularity="minute"
shouldCloseOnSelect={false}
{...field}
/>
</Form.Control>

View File

@@ -41,7 +41,6 @@ const PriceListConfigurationSchema = z.object({
const STACKED_MODAL_ID = "cg"
// TODO: Fix DatePickers once new version is merged.
export const PriceListConfigurationForm = ({
priceList,
customerGroups,
@@ -122,7 +121,7 @@ export const PriceListConfigurationForm = ({
<Form.Field
control={form.control}
name="starts_at"
render={({ field: { value, onChange, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-1 gap-3">
@@ -135,12 +134,10 @@ export const PriceListConfigurationForm = ({
</Form.Hint>
</div>
<Form.Control>
{/* TODO: Add timepicker see CORE-2382 */}
<DatePicker
mode="single"
granularity="minute"
shouldCloseOnSelect={false}
{...field}
onChange={(value) => onChange(value ?? null)}
value={value ?? undefined}
/>
</Form.Control>
</div>
@@ -153,7 +150,7 @@ export const PriceListConfigurationForm = ({
<Form.Field
control={form.control}
name="ends_at"
render={({ field: { value, onChange, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-1 gap-3">
@@ -167,10 +164,9 @@ export const PriceListConfigurationForm = ({
</div>
<Form.Control>
<DatePicker
mode="single"
granularity="minute"
shouldCloseOnSelect={false}
{...field}
onChange={(value) => onChange(value ?? null)}
value={value ?? undefined}
/>
</Form.Control>
</div>

View File

@@ -46,7 +46,6 @@ type PriceListCreateFormProps = {
currencies: HttpTypes.AdminStoreCurrency[]
}
// TODO: Fix DatePickers once new version is merged.
export const PriceListCreateForm = ({
regions,
currencies,

View File

@@ -173,7 +173,7 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
<Form.Field
control={form.control}
name="starts_at"
render={({ field: { value, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@@ -186,11 +186,10 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
</Form.Hint>
</div>
<Form.Control>
{/* TODO: Add timepicker see CORE-2382 */}
<DatePicker
mode="single"
granularity="minute"
shouldCloseOnSelect={false}
{...field}
value={value ?? undefined}
/>
</Form.Control>
</div>
@@ -203,7 +202,7 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
<Form.Field
control={form.control}
name="ends_at"
render={({ field: { value, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@@ -215,9 +214,9 @@ export const PriceListDetailsForm = ({ form }: PriceListDetailsFormProps) => {
</div>
<Form.Control>
<DatePicker
mode="single"
granularity="minute"
shouldCloseOnSelect={false}
{...field}
value={value ?? undefined}
/>
</Form.Control>
</div>

View File

@@ -34,7 +34,7 @@ export const PriceListDetails = () => {
)
})}
<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} />
<PriceListProductSection priceList={price_list} />
{after.widgets.map((w, i) => {
@@ -48,7 +48,7 @@ export const PriceListDetails = () => {
<JsonViewSection data={price_list} />
</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) => {
return (
<div key={i}>

View File

@@ -81,34 +81,35 @@
},
"dependencies": {
"@medusajs/icons": "^1.2.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.4",
"@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-portal": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.4",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.6",
"@react-aria/datepicker": "^3.5.0",
"@react-stately/datepicker": "^3.5.0",
"@radix-ui/react-accordion": "1.2.0",
"@radix-ui/react-alert-dialog": "1.1.1",
"@radix-ui/react-avatar": "1.1.0",
"@radix-ui/react-checkbox": "1.1.1",
"@radix-ui/react-dialog": "1.1.1",
"@radix-ui/react-dropdown-menu": "2.1.1",
"@radix-ui/react-label": "2.1.0",
"@radix-ui/react-popover": "1.1.1",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-radio-group": "1.2.0",
"@radix-ui/react-scroll-area": "1.1.0",
"@radix-ui/react-select": "2.1.1",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-switch": "1.1.0",
"@radix-ui/react-tabs": "1.1.0",
"@radix-ui/react-tooltip": "1.1.2",
"clsx": "^1.2.1",
"copy-to-clipboard": "^3.3.3",
"cva": "1.0.0-beta.1",
"date-fns": "^2.30.0",
"prism-react-renderer": "^2.0.6",
"prismjs": "^1.29.0",
"react-aria": "^3.33.1",
"react-currency-input-field": "^3.6.11",
"react-day-picker": "^8.8.0",
"react-stately": "^3.31.1",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.1"
"tailwind-merge": "^2.2.1",
"upgrade": "^1.1.0",
"yarn": "^1.22.22"
},
"peerDependencies": {
"react": "^18.0.0",

View File

@@ -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,
},
}

View File

@@ -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 }

View File

@@ -1 +0,0 @@
export * from "./calendar"

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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
},
},
}

View File

@@ -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 }

View File

@@ -0,0 +1,2 @@
export * from "./_internal-calendar"
export * from "./calendar"

View File

@@ -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 }

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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()
})
})
})

View File

@@ -1,243 +1,137 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { DateRange } from "react-day-picker"
import { Button } from "@/components/button"
import { ComponentPropsWithoutRef } from "react"
import { Button } from "../button"
import { Drawer } from "../drawer"
import { Label } from "../label"
import { DatePicker } from "./date-picker"
import { Popover } from "@/components/popover"
const meta: Meta<typeof DatePicker> = {
title: "Components/DatePicker",
title: "Components/DatePickerNew",
component: DatePicker,
parameters: {
layout: "centered",
},
render: (args) => {
return (
<div className="w-[200px]">
<DatePicker {...args} />
</div>
)
},
}
export default meta
type Story = StoryObj<typeof DatePicker>
const presets = [
{
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 = {
export const Default: Story = {
args: {
presets,
"aria-label": "Select a date",
},
}
export const SingleWithTimePicker: Story = {
args: {
showTimePicker: true,
},
}
const today = new Date() // Today
const oneWeekFromToday = new Date(new Date(today as Date).setDate(today.getDate() + 7)) // Today + 7 days
export const SingleWithTimePickerAndPresets: Story = {
args: {
showTimePicker: true,
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)
const ControlledDemo = (args: ComponentPropsWithoutRef<typeof DatePicker>) => {
const [startDate, setStartDate] = React.useState<Date | null>(today)
const [endDate, setEndDate] = React.useState<Date | null>(oneWeekFromToday)
return (
<div className="flex w-[200px] flex-col gap-y-4">
<DatePicker
value={value}
onChange={(value) => {
setValue(value)
}}
/>
<Button onClick={() => setValue(undefined)}>Reset</Button>
<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
id="starts_at:r1"
aria-labelledby="starts_at:r1:label"
{...args}
maxValue={endDate || undefined}
value={startDate}
onChange={setStartDate}
/>
</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>
)
}
export const Controlled: Story = {
render: () => <ControlledDemo />,
render: ControlledDemo,
args: {
className: "w-[230px]",
},
}
const ControlledRangeDemo = () => {
const [value, setValue] = React.useState<DateRange | undefined>(undefined)
export const MinValue: Story = {
args: {
"aria-label": "Select a date",
className: "w-[230px]",
minValue: new Date(),
},
}
React.useEffect(() => {
console.log("Value changed: ", value)
}, [value])
export const MaxValue: Story = {
args: {
"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 (
<div className="flex w-[200px] flex-col gap-y-4">
<DatePicker
mode="range"
value={value}
onChange={(value) => {
setValue(value)
}}
/>
<Button onClick={() => setValue(undefined)}>Reset</Button>
</div>
<Drawer>
<Drawer.Trigger asChild>
<Button size="small">Open Drawer</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Select a date</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<div className="p-4">
<DatePicker {...args} />
</div>
</Drawer.Body>
</Drawer.Content>
</Drawer>
)
}
export const ControlledRange: Story = {
render: () => <ControlledRangeDemo />,
}
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 />,
export const InDrawer: Story = {
render: InDrawerDemo,
}

View File

@@ -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 }

View File

@@ -0,0 +1 @@
export * from "./date-segment"

View File

@@ -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 }

View File

@@ -0,0 +1 @@
export * from "./i18n-provider"

View File

@@ -37,7 +37,8 @@ const Close = React.forwardRef<
})
Close.displayName = "Popover.Close"
interface ContentProps extends React.ComponentPropsWithoutRef<typeof Primitives.Content> {}
interface ContentProps
extends React.ComponentPropsWithoutRef<typeof Primitives.Content> {}
/**
* @excludeExternal

View File

@@ -56,8 +56,8 @@ Root.displayName = "Prompt"
const Trigger = Primitives.Trigger
Trigger.displayName = "Prompt.Trigger"
const Portal = ({ className, ...props }: Primitives.AlertDialogPortalProps) => {
return <Primitives.AlertDialogPortal className={clx(className)} {...props} />
const Portal = (props: Primitives.AlertDialogPortalProps) => {
return <Primitives.AlertDialogPortal {...props} />
}
Portal.displayName = "Prompt.Portal"

View File

@@ -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,
},
}

View File

@@ -1,131 +1,44 @@
"use client"
import * as React from "react"
import {
AriaTimeFieldProps,
TimeValue,
useDateSegment,
useLocale,
useTimeField,
} from "@react-aria/datepicker"
import {
useTimeFieldState,
type DateFieldState,
type DateSegment,
} from "@react-stately/datepicker"
import * as React from "react"
} from "react-aria"
import { useTimeFieldState } from "react-stately"
import { inputBaseStyles } from "@/components/input"
import { DateSegment } from "@/components/date-segment"
import { clx } from "@/utils/clx"
type TimeSegmentProps = {
segment: DateSegment
state: DateFieldState
}
const TimeSegment = ({ segment, state }: TimeSegmentProps) => {
const TimeInput = (props: AriaTimeFieldProps<TimeValue>) => {
const ref = React.useRef<HTMLDivElement>(null)
const { segmentProps } = useDateSegment(segment, state, ref)
const isColon = segment.type === "literal" && segment.text === ":"
const isSpace = segment.type === "literal" && segment.text === ""
const isDecorator = isColon || isSpace
const { locale } = useLocale()
const state = useTimeFieldState({
...props,
locale,
})
const { fieldProps } = useTimeField(props, state, ref)
return (
<div
{...segmentProps}
ref={ref}
{...fieldProps}
aria-label="Time input"
className={clx(
"txt-compact-small w-full rounded-md px-2 py-1 text-left uppercase tabular-nums",
inputBaseStyles,
"group-aria-[invalid=true]/time-input:!shadow-borders-error group-invalid/time-input:!shadow-borders-error",
"bg-ui-bg-field shadow-borders-base txt-compact-small flex items-center rounded-md px-2 py-1",
{
"text-ui-fg-muted !w-fit border-none bg-transparent px-0 shadow-none":
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,
"": props.isDisabled,
}
)}
>
<span
aria-hidden="true"
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}
{state.segments.map((segment, index) => {
return <DateSegment key={index} segment={segment} state={state} />
})}
</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 }

View File

@@ -2,7 +2,7 @@ export { Alert } from "./components/alert"
export { Avatar } from "./components/avatar"
export { Badge } from "./components/badge"
export { Button } from "./components/button"
export { Calendar } from "./components/calendar"
export { Calendar } from "./components/calender"
export { Checkbox } from "./components/checkbox"
export { Code } from "./components/code"
export { CodeBlock } from "./components/code-block"
@@ -17,6 +17,7 @@ export { DropdownMenu } from "./components/dropdown-menu"
export { FocusModal } from "./components/focus-modal"
export { Heading } from "./components/heading"
export { Hint } from "./components/hint"
export { I18nProvider } from "./components/i18n-provider"
export { IconBadge } from "./components/icon-badge"
export { IconButton } from "./components/icon-button"
export { Input } from "./components/input"

View File

@@ -53,3 +53,5 @@ export type ToastAction = {
*/
variant?: ToastActionVariant
}
export type Granularity = "day" | "hour" | "minute" | "second"

View File

@@ -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,
}

2325
yarn.lock

File diff suppressed because it is too large Load Diff