diff --git a/package-lock.json b/package-lock.json index d1e88d40..d7c03ffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@radix-ui/react-tooltip": "^1.0.7", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "date-fns": "^2.30.0", + "react-day-picker": "^8.9.1", "tailwind-merge": "^2.0.0" }, "devDependencies": { @@ -9130,6 +9132,21 @@ "node": ">=8" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -15709,6 +15726,19 @@ "react": "^16.3.0 || ^17.0.1 || ^18.0.0" } }, + "node_modules/react-day-picker": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.9.1.tgz", + "integrity": "sha512-W0SPApKIsYq+XCtfGeMYDoU0KbsG3wfkYtlw8l+vZp6KoBXGOlhzBUp4tNx1XiwiOZwhfdGOlj7NGSCKGSlg5Q==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-docgen": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-6.0.4.tgz", diff --git a/package.json b/package.json index dc9617b9..49a5cc60 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,8 @@ "@radix-ui/react-tooltip": "^1.0.7", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "date-fns": "^2.30.0", + "react-day-picker": "^8.9.1", "tailwind-merge": "^2.0.0" } } diff --git a/src/assets/build/arrow-left.icon.tsx b/src/assets/build/arrow-left.icon.tsx new file mode 100644 index 00000000..f33391b0 --- /dev/null +++ b/src/assets/build/arrow-left.icon.tsx @@ -0,0 +1,18 @@ +const ArrowLeftIcon = (props: any) => ( + +) +export default ArrowLeftIcon diff --git a/src/assets/build/arrow-right.icon.tsx b/src/assets/build/arrow-right.icon.tsx new file mode 100644 index 00000000..c411a5eb --- /dev/null +++ b/src/assets/build/arrow-right.icon.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' +const ArrowRightIcon = (props: any) => ( + +) +export default ArrowRightIcon diff --git a/src/assets/index.ts b/src/assets/index.ts index ae0516b6..0fe6b52a 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -11,3 +11,5 @@ export * from './build/folder.icon' export * from './build/circle-outline.icon' export * from './build/angle-down.icon' export * from './build/calendar.icon' +export * from './build/arrow-left.icon' +export * from './build/arrow-right.icon' diff --git a/src/components/Calendar/index.tsx b/src/components/Calendar/index.tsx new file mode 100644 index 00000000..ad363df6 --- /dev/null +++ b/src/components/Calendar/index.tsx @@ -0,0 +1,131 @@ +'use client' + +import * as React from 'react' +import { DayPicker } from 'react-day-picker' +import { cn } from '@/lib/utils' +import ArrowLeftIcon from '@/assets/build/arrow-left.icon' +import ArrowRightIcon from '@/assets/build/arrow-right.icon' +import { cva } from 'class-variance-authority' +export type CalendarProps = React.ComponentProps + +const calendarVariants = cva([], { + variants: { + component: { + months: ['flex', 'flex-col', 'gap-2'], + month: ['flex', 'flex-col', 'gap-2'], + caption: ['flex justify-center py-0.5 relative items-center'], + caption_label: ['text-sm', 'font-medium'], + nav: ['h-7', 'bg-transparent', 'dark:text-foreground-muted-dark'], + nav_button: ['h-7', 'w-7', 'bg-transparent', 'p-0'], + head_cell: [ + 'text-foreground-muted', + 'dark:text-foreground-muted-dark', + 'px-1', + 'py-2', + 'w-9', + 'font-semibold', + 'text-xs', + 'h-[34px]' + ], + cell: [ + [ + 'overflow-none', + 'w-9', + 'p-0', + 'flex', + 'flex-col', + 'items-center', + 'text-center', + 'gap-1', + 'text-sm', + 'relative' + ], + [ + 'focus-within:relative', + 'focus-within:z-20', + 'focus-visible:shadow-blue' + ] + ], + day: cn([ + 'aria-selected:bg-accent', + 'rounded-lg', + 'dark:aria-selected:bg-blue-400', + 'px-1', + 'w-full', + 'py-2', + 'h-[34px]', + 'flex', + 'flex-col', + 'text-sm', + 'font-bold', + 'aria-selected:opacity-100', + 'items-center', + 'shrink-0', + 'focus-visible:shadow-blue', + 'outline-none' + ]), + day_selected: ['text-foreground-inverse'], + day_outside: [ + 'day-outside', + 'text-muted-foreground', + 'opacity-50 aria-selected:bg-accent-alt' + ] + } + } +}) + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + *]:flex-row', + className + )} + classNames={{ + months: calendarVariants({ component: 'months' }), + month: calendarVariants({ component: 'months' }), + caption: calendarVariants({ component: 'caption' }), + caption_label: calendarVariants({ component: 'caption_label' }), + nav: calendarVariants({ component: 'nav' }), + nav_button: calendarVariants({ component: 'nav_button' }), + nav_button_previous: 'absolute left-1', + nav_button_next: 'absolute right-0', + table: 'w-full border-collapse space-y-0', + head_row: 'flex', + head_cell: calendarVariants({ component: 'head_cell' }), + row: 'flex w-full', + cell: calendarVariants({ component: 'cell' }), + day: calendarVariants({ component: 'day' }), + day_range_end: + 'day-range-end bg-accent dark:bg-blue-[400] rounded-r-full', + day_range_start: + 'day-range-start rounded-l-full bg-accent dark:bg-blue-[400] text-foreground-inverse', + day_selected: calendarVariants({ component: 'day_selected' }), + day_outside: calendarVariants({ component: 'day_outside' }), + day_disabled: 'text-muted-foreground opacity-50', + day_range_middle: + 'day-range-middle aria-selected:bg-accent-alt dark:aria-selected:bg-accent-alt rounded-none bg-grey-400 text-grey-900', + day_hidden: 'invisible', + ...classNames + }} + components={{ + IconLeft: () => ( + + ), + IconRight: () => ( + + ) + }} + {...props} + /> + ) +} +Calendar.displayName = 'Calendar' + +export { Calendar } diff --git a/src/components/index.ts b/src/components/index.ts index b48b111c..a713d31c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,3 +10,4 @@ export * from './Switch' export * from './Chip' export * from './WebsiteFooter' export * from './Select' +export * from './Calendar' diff --git a/stories/Calendar/Calendar.example.tsx b/stories/Calendar/Calendar.example.tsx new file mode 100644 index 00000000..0c0e4eb3 --- /dev/null +++ b/stories/Calendar/Calendar.example.tsx @@ -0,0 +1,37 @@ +'use client' +import { addDays } from 'date-fns' +import { useState } from 'react' +import { DateRange } from 'react-day-picker' + +import * as React from 'react' +import { Calendar, CalendarProps } from '@/index' + +export function CalendarDemo({ + mode = 'single', + numberOfMonths = 1 +}: CalendarProps) { + if (mode === 'single' || mode === 'default') { + const [date, setDate] = React.useState(new Date()) + + return + } else if (mode === 'multiple') { + const [days, setDays] = React.useState() + return + } else if (mode === 'range') { + const today = new Date() + const defaultSelected: DateRange = { + from: today, + to: addDays(today, 7) + } + const [range, setRange] = useState(defaultSelected) + return ( + + ) + } +} diff --git a/stories/Calendar/Calendar.stories.ts b/stories/Calendar/Calendar.stories.ts new file mode 100644 index 00000000..483a0636 --- /dev/null +++ b/stories/Calendar/Calendar.stories.ts @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { CalendarDemo } from './Calendar.example' + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta = { + title: 'Components/Calendar', + component: CalendarDemo, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout + layout: 'centered' + } + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes +} satisfies Meta + +export default meta +type Story = StoryObj + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Default: Story = {} + +export const MultipleWithMax: Story = { + args: { + mode: 'multiple' + } +} + +export const Range: Story = { + args: { + mode: 'range' + } +} +export const TwoMonths: Story = { + args: { + mode: 'range', + numberOfMonths: 2 + } +} diff --git a/stories/Calendar/Docs.mdx b/stories/Calendar/Docs.mdx new file mode 100644 index 00000000..132d5687 --- /dev/null +++ b/stories/Calendar/Docs.mdx @@ -0,0 +1,220 @@ +import { Canvas, Meta } from '@storybook/blocks' + +import * as CalendarStories from './Calendar.stories' + + + +# Calendar + +A Calendar component that can represent days, weeks and months. It can run on three different modes; single, multiple and range. These reflect the dates that can be selected. The implementation, alongside all the styling and props, may be found at [React Day Picker](https://react-day-picker.js.org/). Below we outline several attributes we think might be most used. + +## Default / Single + + + +## Multiple + + + +## Range + + + +## Range + + + +## Attributes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeTypeDefault
`mode``undefined | "default" | "single" | "multiple" | "range"``"default"`
`selected``Date`-
`onSelect``function`-
`defaultSelected``Date | Date[] | DateRange`-
`footer``ReactNode`-
`className``string`-
`classNames``string`-
`min``number`-
`max``number`-
+ +There are no extensions from React Day Pickers props to our own. In this sense, it is recommended to rely on the React Day Picker library for up to date information on the component. However it is recommended for a user to disable + +## Calendar + +```tsx +'use client' + +import * as React from 'react' +import { DayPicker } from 'react-day-picker' +import { cn } from '@/lib/utils' +import ArrowLeftIcon from '@/assets/build/arrow-left.icon' +import ArrowRightIcon from '@/assets/build/arrow-right.icon' +import { cva } from 'class-variance-authority' +export type CalendarProps = React.ComponentProps + +const calendarVariants = cva([], { + variants: { + component: { + months: ['flex', 'flex-col', 'gap-2'], + month: ['flex', 'flex-col', 'gap-2'], + caption: ['flex justify-center py-0.5 relative items-center'], + caption_label: ['text-sm', 'font-medium'], + nav: ['h-7', 'bg-transparent', 'dark:text-foreground-muted-dark'], + nav_button: ['h-7', 'w-7', 'bg-transparent', 'p-0'], + head_cell: [ + 'text-foreground-muted', + 'dark:text-foreground-muted-dark', + 'px-1', + 'py-2', + 'w-9', + 'font-semibold', + 'text-xs', + 'h-[34px]' + ], + cell: [ + [ + 'overflow-none', + 'w-9', + 'p-0', + 'flex', + 'flex-col', + 'items-center', + 'text-center', + 'gap-1', + 'text-sm', + 'relative' + ], + [ + 'focus-within:relative', + 'focus-within:z-20', + 'focus-visible:shadow-blue' + ] + ], + day: cn([ + 'aria-selected:bg-accent', + 'rounded-lg', + 'dark:aria-selected:bg-blue-400', + 'px-1', + 'w-full', + 'py-2', + 'h-[34px]', + 'flex', + 'flex-col', + 'text-sm', + 'font-bold', + 'aria-selected:opacity-100', + 'items-center', + 'shrink-0', + 'focus-visible:shadow-blue', + 'outline-none' + ]), + day_selected: ['text-foreground-inverse'], + day_outside: [ + 'day-outside', + 'text-muted-foreground', + 'opacity-50 aria-selected:bg-accent-alt' + ] + } + } +}) + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + *]:flex-row', + className + )} + classNames={{ + months: calendarVariants({ component: 'months' }), + month: calendarVariants({ component: 'months' }), + caption: calendarVariants({ component: 'caption' }), + caption_label: calendarVariants({ component: 'caption_label' }), + nav: calendarVariants({ component: 'nav' }), + nav_button: calendarVariants({ component: 'nav_button' }), + nav_button_previous: 'absolute left-1', + nav_button_next: 'absolute right-0', + table: 'w-full border-collapse space-y-0', + head_row: 'flex', + head_cell: calendarVariants({ component: 'head_cell' }), + row: 'flex w-full', + cell: calendarVariants({ component: 'cell' }), + day: calendarVariants({ component: 'day' }), + day_range_end: + 'day-range-end bg-accent dark:bg-blue-[400] rounded-r-full', + day_range_start: + 'day-range-start rounded-l-full bg-accent dark:bg-blue-[400] text-foreground-inverse', + day_selected: calendarVariants({ component: 'day_selected' }), + day_outside: calendarVariants({ component: 'day_outside' }), + day_disabled: 'text-muted-foreground opacity-50', + day_range_middle: + 'day-range-middle aria-selected:bg-accent-alt dark:aria-selected:bg-accent-alt rounded-none bg-grey-400 text-grey-900', + day_hidden: 'invisible', + ...classNames + }} + components={{ + IconLeft: () => ( + + ), + IconRight: () => ( + + ) + }} + {...props} + /> + ) +} +Calendar.displayName = 'Calendar' + +export { Calendar } +```