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:
committed by
GitHub
parent
074e4a888e
commit
a84e5a6ced
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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 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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 * 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,7 +46,6 @@ type PriceListCreateFormProps = {
|
||||
currencies: HttpTypes.AdminStoreCurrency[]
|
||||
}
|
||||
|
||||
// TODO: Fix DatePickers once new version is merged.
|
||||
export const PriceListCreateForm = ({
|
||||
regions,
|
||||
currencies,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
110
packages/design-system/ui/src/components/calender/calendar.tsx
Normal file
110
packages/design-system/ui/src/components/calender/calendar.tsx
Normal 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 }
|
||||
@@ -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 * 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,
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
interface ContentProps extends React.ComponentPropsWithoutRef<typeof Primitives.Content> {}
|
||||
interface ContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof Primitives.Content> {}
|
||||
|
||||
/**
|
||||
* @excludeExternal
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -53,3 +53,5 @@ export type ToastAction = {
|
||||
*/
|
||||
variant?: ToastActionVariant
|
||||
}
|
||||
|
||||
export type Granularity = "day" | "hour" | "minute" | "second"
|
||||
|
||||
138
packages/design-system/ui/src/utils/calendar.ts
Normal file
138
packages/design-system/ui/src/utils/calendar.ts
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user