diff --git a/.changeset/popular-seals-appear.md b/.changeset/popular-seals-appear.md new file mode 100644 index 0000000000..0869051cd3 --- /dev/null +++ b/.changeset/popular-seals-appear.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/calendar": minor +--- + +Added the `renderCellContent` prop to the `Calendar` and `RangeCalendar` components, allowing developers to specify custom content for each calendar cell. Updated the existing tests and added new test cases to cover the custom cell content functionality. diff --git a/apps/docs/content/components/calendar/custom-cell-content.raw.jsx b/apps/docs/content/components/calendar/custom-cell-content.raw.jsx new file mode 100644 index 0000000000..d03423a70b --- /dev/null +++ b/apps/docs/content/components/calendar/custom-cell-content.raw.jsx @@ -0,0 +1,50 @@ +import React from "react"; +import { + Calendar, + CalendarCellContent, + CalendarCellHeader, + CalendarCellBody, +} from "@nextui-org/react"; + +export default function App() { + return ( + + {(date) => ( + + + +
+ {date.day % 7 === 0 && ( + + MTG + + )} + {date.day % 5 === 0 && ( + + MTG + + )} + {date.day % 3 === 0 && ( + + MTG + + )} +
+
+
+ )} +
+ ); +} diff --git a/apps/docs/content/components/calendar/custom-cell-content.tsx b/apps/docs/content/components/calendar/custom-cell-content.tsx new file mode 100644 index 0000000000..4d15cd37c5 --- /dev/null +++ b/apps/docs/content/components/calendar/custom-cell-content.tsx @@ -0,0 +1,9 @@ +import App from "./custom-cell-content.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/calendar/index.ts b/apps/docs/content/components/calendar/index.ts index b0f687a6f5..c328c77c82 100644 --- a/apps/docs/content/components/calendar/index.ts +++ b/apps/docs/content/components/calendar/index.ts @@ -12,6 +12,7 @@ import internationalCalendars from "./international-calendars"; import visibleMonths from "./visible-months"; import pageBehaviour from "./page-behaviour"; import presets from "./presets"; +import customCellContent from "./custom-cell-content"; export const calendarContent = { usage, @@ -28,4 +29,5 @@ export const calendarContent = { visibleMonths, pageBehaviour, presets, + customCellContent, }; diff --git a/apps/docs/content/components/range-calendar/custom-cell-content.raw.jsx b/apps/docs/content/components/range-calendar/custom-cell-content.raw.jsx new file mode 100644 index 0000000000..d9acec7a7e --- /dev/null +++ b/apps/docs/content/components/range-calendar/custom-cell-content.raw.jsx @@ -0,0 +1,49 @@ +import { + RangeCalendar, + CalendarCellContent, + CalendarCellHeader, + CalendarCellBody, +} from "@nextui-org/react"; + +export default function App() { + return ( + + {(date) => ( + + + +
+ {date.day % 7 === 0 && ( + + MTG + + )} + {date.day % 5 === 0 && ( + + MTG + + )} + {date.day % 3 === 0 && ( + + MTG + + )} +
+
+
+ )} +
+ ); +} diff --git a/apps/docs/content/components/range-calendar/custom-cell-content.tsx b/apps/docs/content/components/range-calendar/custom-cell-content.tsx new file mode 100644 index 0000000000..4d15cd37c5 --- /dev/null +++ b/apps/docs/content/components/range-calendar/custom-cell-content.tsx @@ -0,0 +1,9 @@ +import App from "./custom-cell-content.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/range-calendar/index.ts b/apps/docs/content/components/range-calendar/index.ts index 37396dac6d..27856017ab 100644 --- a/apps/docs/content/components/range-calendar/index.ts +++ b/apps/docs/content/components/range-calendar/index.ts @@ -12,6 +12,7 @@ import internationalCalendars from "./international-calendars"; import visibleMonths from "./visible-months"; import pageBehaviour from "./page-behaviour"; import presets from "./presets"; +import customCellContent from "./custom-cell-content"; import withMonthAndYearPicker from "./with-month-and-year-picker"; export const rangeCalendarContent = { @@ -29,5 +30,6 @@ export const rangeCalendarContent = { visibleMonths, pageBehaviour, presets, + customCellContent, withMonthAndYearPicker, }; diff --git a/apps/docs/content/docs/components/calendar.mdx b/apps/docs/content/docs/components/calendar.mdx index dd7513bf8f..46502c3924 100644 --- a/apps/docs/content/docs/components/calendar.mdx +++ b/apps/docs/content/docs/components/calendar.mdx @@ -129,6 +129,20 @@ Here's the example to customize `topContent` and `bottomContent` to have some pr +### Custom Cell Content + +The Calendar component supports customizing the cell content in two ways: + + + +The Calendar provides three components for cell customization: + +- `CalendarCellContent`: The wrapper component for the cell content +- `CalendarCellHeader`: The interactive header element that handles selection +- `CalendarCellBody`: Additional content container below the button + +These components inherit all calendar states (selected, disabled, etc.) and maintain proper accessibility. + ## Slots - **base**: Calendar wrapper, it handles alignment, placement, and general appearance. @@ -146,6 +160,10 @@ Here's the example to customize `topContent` and `bottomContent` to have some pr - **gridBodyRow**: The date grid body row element (e.g. ``). - **cell**: The date grid cell element (e.g. ``). - **cellButton**: The button element within the cell. +- **cellContent**: The wrapper for custom cell content. +- **cellHeaderWrapper**: The wrapper for the cell header content. +- **cellHeader**: The header element within the cell that handles selection. +- **cellBody**: The container for additional cell content. - **pickerWrapper**: The wrapper for the picker - **pickerMonthList**: The month list picker. - **pickerYearList**: The year list picker. diff --git a/apps/docs/content/docs/components/range-calendar.mdx b/apps/docs/content/docs/components/range-calendar.mdx index 1028e80249..e83c848c57 100644 --- a/apps/docs/content/docs/components/range-calendar.mdx +++ b/apps/docs/content/docs/components/range-calendar.mdx @@ -13,6 +13,7 @@ A range calendar consists of a grouping element containing one or more date grid --- + ## Installation @@ -32,8 +33,8 @@ A range calendar consists of a grouping element containing one or more date grid @@ -45,6 +46,56 @@ Date values are provided using objects in the [@internationalized/date](https:// +### Custom Cell Content + +The Calendar component supports customizing the cell content in two ways: + +1. Using a render function: +```tsx + + {(date, cellState) => ( + + + + {date.day} + + + + )} + +``` + +2. Using component composition: +```tsx + + + + +
+ + + +
+
+
+
+``` + +The calendar provides three components for cell customization: +- `CalendarCellContent`: Wrapper component for the entire cell content +- `CalendarCellHeader`: Interactive button element that handles selection +- `CalendarCellBody`: Container for additional content below the button + +These components inherit all calendar states (selected, disabled, etc.) and maintain proper accessibility. + + + ### Disabled The `isDisabled` boolean prop makes the Calendar disabled. Cells cannot be focused or selected. @@ -137,6 +188,21 @@ Here's the example to customize `topContent` and `bottomContent` to have some pr +### Custom Cell Content + +The Calendar component supports customizing the cell content in two ways: + + + +The Calendar provides three components for cell customization: + +- `CalendarCellContent`: The wrapper component for the cell content +- `CalendarCellHeader`: The interactive header element that handles selection +- `CalendarCellBody`: Additional content container below the button + +These components inherit all calendar states (selected, disabled, etc.) and maintain proper accessibility. + + ## Slots - **base**: Calendar wrapper, it handles alignment, placement, and general appearance. @@ -154,6 +220,10 @@ Here's the example to customize `topContent` and `bottomContent` to have some pr - **gridBodyRow**: The date grid body row element (e.g. ``). - **cell**: The date grid cell element (e.g. ``). - **cellButton**: The button element within the cell. +- **cellContent**: The wrapper for custom cell content. +- **cellHeaderWrapper**: The wrapper for the cell header content. +- **cellHeader**: The header element within the cell that handles selection. +- **cellBody**: The container for additional cell content. - **pickerWrapper**: The wrapper for the picker - **pickerMonthList**: The month list picker. - **pickerYearList**: The year list picker. diff --git a/packages/components/calendar/__tests__/calendar.test.tsx b/packages/components/calendar/__tests__/calendar.test.tsx index f7ee51aff9..5e62bb7d40 100644 --- a/packages/components/calendar/__tests__/calendar.test.tsx +++ b/packages/components/calendar/__tests__/calendar.test.tsx @@ -464,4 +464,25 @@ describe("Calendar", () => { expect(year).toHaveAttribute("data-value", "2567"); }); }); + + describe("Custom cell content", () => { + it("should render custom content in the calendar cells", () => { + const wrapper = render( + + {(date) => ( +
+ {date.day} + * +
+ )} +
, + ); + + const gridCells = wrapper.getAllByRole("gridcell"); + const customContentCell = gridCells.find((cell) => cell.textContent === "31*"); + + expect(customContentCell).not.toBeNull(); + expect(customContentCell).toHaveTextContent("31*"); + }); + }); }); diff --git a/packages/components/calendar/__tests__/range-calendar.test.tsx b/packages/components/calendar/__tests__/range-calendar.test.tsx index c1ac49944e..532acc4dc7 100644 --- a/packages/components/calendar/__tests__/range-calendar.test.tsx +++ b/packages/components/calendar/__tests__/range-calendar.test.tsx @@ -748,4 +748,30 @@ describe("RangeCalendar", () => { expect(end).toEqual(new CalendarDate(2019, 6, 25)); }); }); + + describe("Custom cell content", () => { + it("should render custom content in the range calendar cells", () => { + const wrapper = render( + + {(date) => ( +
+ {date.day} + * +
+ )} +
, + ); + + const gridCells = wrapper.getAllByRole("gridcell"); + const customContentCellA = gridCells.find((cell) => cell.textContent === "25*"); + const customContentCellB = gridCells.find((cell) => cell.textContent === "26*"); + + expect(customContentCellA).not.toBeNull(); + expect(customContentCellA).toHaveTextContent("25*"); + expect(customContentCellB).not.toBeNull(); + expect(customContentCellB).toHaveTextContent("26*"); + }); + }); }); diff --git a/packages/components/calendar/src/calendar-cell-body.tsx b/packages/components/calendar/src/calendar-cell-body.tsx new file mode 100644 index 0000000000..55c734fdf9 --- /dev/null +++ b/packages/components/calendar/src/calendar-cell-body.tsx @@ -0,0 +1,29 @@ +import type {HTMLNextUIProps} from "@nextui-org/system"; + +import React from "react"; + +import {useCalendarContext} from "./calendar-context"; + +interface Props extends HTMLNextUIProps<"div"> { + children: React.ReactNode; +} + +export type CalendarCellBodyProps = Props; + +export const CalendarCellBody = React.forwardRef( + ({children, ...props}, ref) => { + const {slots, classNames} = useCalendarContext(); + const bodyProps = { + ...props, + ref: ref, + className: slots?.cellBody({class: classNames?.cellBody}), + "data-slot": "cell-body", + }; + + return
{children}
; + }, +); + +CalendarCellBody.displayName = "NextUI.CalendarCellBody"; + +export default CalendarCellBody; diff --git a/packages/components/calendar/src/calendar-cell-content-default.tsx b/packages/components/calendar/src/calendar-cell-content-default.tsx new file mode 100644 index 0000000000..b0cfecd32d --- /dev/null +++ b/packages/components/calendar/src/calendar-cell-content-default.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import {CalendarDate} from "@internationalized/date"; + +import {CalendarCellContent} from "./calendar-cell-content"; +import {CalendarCellHeader} from "./calendar-cell-header"; + +export interface CalendarCellContentDefaultProps { + date: CalendarDate; +} + +export const CalendarCellContentDefault: React.FC = ({date}) => { + return ( + + {date.day} + + ); +}; + +CalendarCellContentDefault.displayName = "NextUI.CalendarCellContentDefault"; diff --git a/packages/components/calendar/src/calendar-cell-content.tsx b/packages/components/calendar/src/calendar-cell-content.tsx new file mode 100644 index 0000000000..948a84cbcf --- /dev/null +++ b/packages/components/calendar/src/calendar-cell-content.tsx @@ -0,0 +1,25 @@ +import type {HTMLNextUIProps} from "@nextui-org/system"; + +import {useCalendarContext} from "./calendar-context"; + +export interface CalendarCellContentProps extends HTMLNextUIProps<"div"> { + children: React.ReactNode; +} + +export const CalendarCellContent = ({children, ...props}: CalendarCellContentProps) => { + const {slots, classNames} = useCalendarContext(); + + return ( +
+ {children} +
+ ); +}; + +CalendarCellContent.displayName = "NextUI.CalendarCellContent"; + +export default CalendarCellContent; diff --git a/packages/components/calendar/src/calendar-cell-context.tsx b/packages/components/calendar/src/calendar-cell-context.tsx new file mode 100644 index 0000000000..6b4ed84475 --- /dev/null +++ b/packages/components/calendar/src/calendar-cell-context.tsx @@ -0,0 +1,45 @@ +import type {CalendarState, RangeCalendarState} from "@react-stately/calendar"; +import type {CalendarDate} from "@internationalized/date"; +import type {DOMAttributes} from "@react-types/shared"; + +import {createContext} from "@nextui-org/react-utils"; + +export interface CalendarCellContextType { + // Core date and state + date: CalendarDate; + state: CalendarState | RangeCalendarState; + buttonProps: DOMAttributes; + formattedDate: string; + + // Selection states + isSelected: boolean; + isRangeSelection: boolean; + isRangeStart: boolean; + isRangeEnd: boolean; + isSelectionStart: boolean; + isSelectionEnd: boolean; + + // Interaction states + isDisabled: boolean; + isPressable: boolean; + isPressed: boolean; + isFocused: boolean; + isFocusVisible: boolean; + isHovered: boolean; + + // Validation states + isInvalid: boolean; + isUnavailable: boolean; + + // Display states + isOutsideMonth: boolean; + isToday: boolean; + isReadOnly: boolean; +} + +export const [CalendarCellProvider, useCalendarCell] = createContext({ + name: "CalendarCellContext", + strict: true, + errorMessage: + "useCalendarCell: `context` is undefined. Seems you forgot to wrap component within the CalendarCellProvider", +}); diff --git a/packages/components/calendar/src/calendar-cell-header.tsx b/packages/components/calendar/src/calendar-cell-header.tsx new file mode 100644 index 0000000000..6caebfc27f --- /dev/null +++ b/packages/components/calendar/src/calendar-cell-header.tsx @@ -0,0 +1,72 @@ +import type {HTMLNextUIProps} from "@nextui-org/system"; + +import {dataAttr} from "@nextui-org/shared-utils"; + +import {useCalendarCell} from "./calendar-cell-context"; +import {useCalendarContext} from "./calendar-context"; + +export interface CalendarCellHeaderProps extends HTMLNextUIProps<"div"> { + children?: React.ReactNode; +} + +export const CalendarCellHeader = ({children}: CalendarCellHeaderProps) => { + const {slots, classNames} = useCalendarContext(); + const { + date, + state, + isSelected, + isDisabled, + isInvalid, + isOutsideMonth, + isToday, + isUnavailable, + isRangeSelection, + isRangeStart, + isRangeEnd, + isSelectionStart, + isSelectionEnd, + isFocused, + isFocusVisible, + isHovered, + isPressed, + } = useCalendarCell(); + + return ( +
+ + {children ? children : date.day} + +
+ ); +}; + +CalendarCellHeader.displayName = "NextUI.CalendarCellHeader"; + +export default CalendarCellHeader; diff --git a/packages/components/calendar/src/calendar-cell.tsx b/packages/components/calendar/src/calendar-cell.tsx index b2e742c588..0a426b1c99 100644 --- a/packages/components/calendar/src/calendar-cell.tsx +++ b/packages/components/calendar/src/calendar-cell.tsx @@ -9,7 +9,10 @@ import {useLocale} from "@react-aria/i18n"; import {useFocusRing} from "@react-aria/focus"; import {useHover} from "@react-aria/interactions"; import {useRef} from "react"; -import {dataAttr} from "@nextui-org/shared-utils"; + +import {CalendarCellContextType, CalendarCellProvider} from "./calendar-cell-context"; +import {CalendarCellContentDefault} from "./calendar-cell-content-default"; +import {useCalendarContext} from "./calendar-context"; export interface CalendarCellProps extends HTMLNextUIProps<"td">, AriaCalendarCellProps { state: CalendarState | RangeCalendarState; @@ -23,6 +26,7 @@ export function CalendarCell(originalProps: CalendarCellProps) { const {state, slots, isPickerVisible, currentMonth, classNames, ...props} = originalProps; const ref = useRef(null); + const {cellContent} = useCalendarContext(); const { cellProps, @@ -67,29 +71,51 @@ export function CalendarCell(originalProps: CalendarCellProps) { isDisabled: isDisabled || isUnavailable || state.isReadOnly, }); + const cellContextValue: CalendarCellContextType = { + // Core date and state + date: props.date, + state, + buttonProps, + formattedDate, + + // Selection states + isSelected, + isRangeSelection: isSelected && "highlightedRange" in state, + isRangeStart, + isRangeEnd, + isSelectionStart, + isSelectionEnd, + + // Interaction states + isDisabled, + isPressable: !isDisabled && !isUnavailable && !state.isReadOnly, + isPressed, + isFocused, + isFocusVisible, + isHovered, + + // Validation states + isInvalid, + isUnavailable, + + // Display states + isOutsideMonth: !isSameMonth(props.date, currentMonth), + isToday: isToday(props.date, state.timeZone), + isReadOnly: state.isReadOnly, + }; + return ( - {formattedDate} + + {typeof cellContent === "function" + ? cellContent(props.date) + : cellContent ?? } + ); diff --git a/packages/components/calendar/src/calendar.tsx b/packages/components/calendar/src/calendar.tsx index 30891d3c71..ce5ef2e516 100644 --- a/packages/components/calendar/src/calendar.tsx +++ b/packages/components/calendar/src/calendar.tsx @@ -1,4 +1,4 @@ -import type {DateValue} from "@internationalized/date"; +import type {CalendarDate, DateValue} from "@internationalized/date"; import type {ForwardedRef, ReactElement} from "react"; import {forwardRef} from "@nextui-org/system"; @@ -7,7 +7,10 @@ import {UseCalendarProps, useCalendar} from "./use-calendar"; import {CalendarProvider} from "./calendar-context"; import {CalendarBase} from "./calendar-base"; -interface Props extends Omit, "isHeaderWrapperExpanded"> {} +interface Props + extends Omit, "isHeaderWrapperExpanded" | "children"> { + children?: ((date: CalendarDate) => React.ReactNode) | React.ReactNode; +} export type CalendarProps = Props; @@ -15,7 +18,12 @@ const Calendar = forwardRef(function Calendar( props: CalendarProps, ref: ForwardedRef, ) { - const {context, getBaseCalendarProps} = useCalendar({...props, ref}); + const {children, ...otherProps} = props; + const {context, getBaseCalendarProps} = useCalendar({ + ...otherProps, + ref, + cellContent: children, + }); return ( diff --git a/packages/components/calendar/src/index.ts b/packages/components/calendar/src/index.ts index f34634627c..a74bebb668 100644 --- a/packages/components/calendar/src/index.ts +++ b/packages/components/calendar/src/index.ts @@ -1,5 +1,8 @@ import Calendar from "./calendar"; import RangeCalendar from "./range-calendar"; +import CalendarCellContent from "./calendar-cell-content"; +import CalendarCellHeader from "./calendar-cell-header"; +import CalendarCellBody from "./calendar-cell-body"; // export types export type {CalendarProps} from "./calendar"; @@ -16,4 +19,4 @@ export {useRangeCalendar} from "./use-range-calendar"; export {CalendarProvider, useCalendarContext} from "./calendar-context"; // export component -export {Calendar, RangeCalendar}; +export {Calendar, RangeCalendar, CalendarCellContent, CalendarCellHeader, CalendarCellBody}; diff --git a/packages/components/calendar/src/range-calendar.tsx b/packages/components/calendar/src/range-calendar.tsx index 913f2604c8..eda6ba24fb 100644 --- a/packages/components/calendar/src/range-calendar.tsx +++ b/packages/components/calendar/src/range-calendar.tsx @@ -1,4 +1,4 @@ -import type {DateValue} from "@internationalized/date"; +import type {CalendarDate, DateValue} from "@internationalized/date"; import type {ForwardedRef, ReactElement} from "react"; import {forwardRef} from "@nextui-org/system"; @@ -10,8 +10,10 @@ import {CalendarBase} from "./calendar-base"; interface Props extends Omit< UseRangeCalendarProps, - "isHeaderExpanded" | "onHeaderExpandedChange" | "isHeaderWrapperExpanded" - > {} + "isHeaderExpanded" | "onHeaderExpandedChange" | "isHeaderWrapperExpanded" | "children" + > { + children?: ((date: CalendarDate) => React.ReactNode) | React.ReactNode; +} export type RangeCalendarProps = Props; @@ -19,7 +21,12 @@ const RangeCalendar = forwardRef(function RangeCalendar( props: RangeCalendarProps, ref: ForwardedRef, ) { - const {context, getBaseCalendarProps} = useRangeCalendar({...props, ref}); + const {children, ...otherProps} = props; + const {context, getBaseCalendarProps} = useRangeCalendar({ + ...otherProps, + ref, + cellContent: children, + }); return ( diff --git a/packages/components/calendar/src/use-calendar-base.ts b/packages/components/calendar/src/use-calendar-base.ts index e87be7c489..7cfaaefc2e 100644 --- a/packages/components/calendar/src/use-calendar-base.ts +++ b/packages/components/calendar/src/use-calendar-base.ts @@ -81,6 +81,12 @@ interface Props extends NextUIBaseProps { * @returns void */ onHeaderExpandedChange?: (isExpanded: boolean) => void; + /** + * Function to custom render the content of the calendar cell + * @param date The date to render + * @returns ReactNode + */ + cellContent?: ((date: CalendarDate) => React.ReactNode) | React.ReactNode; /** * This function helps to reduce the bundle size by providing a custom calendar system. * @@ -181,6 +187,7 @@ export type ContextType = { setIsHeaderExpanded?: (isExpanded: boolean) => void; classNames?: SlotsToClasses; disableAnimation?: boolean; + cellContent?: ((date: CalendarDate) => React.ReactNode) | React.ReactNode; }; export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { @@ -200,7 +207,6 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { const { ref, as, - children, className, topContent, bottomContent, @@ -315,7 +321,6 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { return { Component, - children, domRef, slots, locale, diff --git a/packages/components/calendar/src/use-calendar.ts b/packages/components/calendar/src/use-calendar.ts index def46e9730..bd4ae7e864 100644 --- a/packages/components/calendar/src/use-calendar.ts +++ b/packages/components/calendar/src/use-calendar.ts @@ -18,12 +18,12 @@ export type UseCalendarProps = UseCalendarBaseProps & AriaC export function useCalendar({ buttonPickerProps: buttonPickerPropsProp, className, + cellContent, ...originalProps }: UseCalendarProps) { const { Component, slots, - children, domRef, locale, minValue, @@ -107,6 +107,7 @@ export function useCalendar({ classNames, showMonthAndYearPickers, disableAnimation, + cellContent, }), [ state, @@ -118,12 +119,12 @@ export function useCalendar({ visibleMonths, disableAnimation, showMonthAndYearPickers, + cellContent, ], ); return { Component, - children, domRef, context, state, diff --git a/packages/components/calendar/src/use-range-calendar.ts b/packages/components/calendar/src/use-range-calendar.ts index 08d9c321e4..635f0054a7 100644 --- a/packages/components/calendar/src/use-range-calendar.ts +++ b/packages/components/calendar/src/use-range-calendar.ts @@ -16,7 +16,7 @@ import {CalendarBaseProps} from "./calendar-base"; type NextUIBaseProps = Omit< HTMLNextUIProps<"div">, - keyof AriaRangeCalendarProps + keyof AriaRangeCalendarProps | "children" >; interface Props extends UseCalendarBaseProps, NextUIBaseProps {} @@ -26,12 +26,12 @@ export type UseRangeCalendarProps = Props & AriaRangeCal export function useRangeCalendar({ buttonPickerProps: buttonPickerPropsProp, className, + cellContent, ...originalProps }: UseRangeCalendarProps) { const { Component, slots, - children, domRef, locale, showHelper, @@ -115,6 +115,7 @@ export function useRangeCalendar({ showMonthAndYearPickers, classNames, disableAnimation, + cellContent, }), [ state, @@ -125,13 +126,13 @@ export function useRangeCalendar({ setIsHeaderExpanded, visibleMonths, disableAnimation, + cellContent, showMonthAndYearPickers, ], ); return { Component, - children, domRef, context, state, diff --git a/packages/components/calendar/stories/calendar.stories.tsx b/packages/components/calendar/stories/calendar.stories.tsx index cf8f410c76..64ffff94fb 100644 --- a/packages/components/calendar/stories/calendar.stories.tsx +++ b/packages/components/calendar/stories/calendar.stories.tsx @@ -8,6 +8,7 @@ import { isWeekend, startOfWeek, startOfMonth, + getDayOfWeek, } from "@internationalized/date"; import {I18nProvider, useLocale} from "@react-aria/i18n"; import {Button, ButtonGroup} from "@nextui-org/button"; @@ -15,7 +16,14 @@ import {Radio, RadioGroup} from "@nextui-org/radio"; import {cn} from "@nextui-org/theme"; import {NextUIProvider} from "@nextui-org/system"; -import {Calendar, CalendarProps, DateValue} from "../src"; +import { + Calendar, + CalendarProps, + DateValue, + CalendarCellContent, + CalendarCellHeader, + CalendarCellBody, +} from "../src"; export default { title: "Components/Calendar", @@ -262,6 +270,72 @@ const CalendarWidthTemplate = (args: CalendarProps) => { ); }; +const CustomCellTemplate = (args: CalendarProps) => { + const {locale} = useLocale(); + + return ( +
+
+ + {(date) => ( + + + +
+ {date.day % 7 === 0 && ( + + MTG + + )} + {date.day % 5 === 0 && ( + + MTG + + )} + {date.day % 3 === 0 && ( + + MTG + + )} +
+
+
+ )} +
+
+
+ + {(date) => { + const dayOfWeek = getDayOfWeek(date, locale); + const style = + dayOfWeek === 0 ? "text-red-500" : dayOfWeek === 6 ? "text-blue-500" : "inherit"; + + return ( + + + {date.day} + + + ); + }} + +
+
+ ); +}; + const ReducedMotionTemplate = (args: CalendarProps) => { return (
@@ -406,6 +480,13 @@ export const CalendarWidth = { }, }; +export const CustomCellContent = { + render: CustomCellTemplate, + args: { + ...defaultProps, + }, +}; + export const ReducedMotion = { render: ReducedMotionTemplate, args: { diff --git a/packages/components/calendar/stories/range-calendar.stories.tsx b/packages/components/calendar/stories/range-calendar.stories.tsx index 44f3398531..6e08f69b64 100644 --- a/packages/components/calendar/stories/range-calendar.stories.tsx +++ b/packages/components/calendar/stories/range-calendar.stories.tsx @@ -12,13 +12,20 @@ import { startOfWeek, endOfMonth, endOfWeek, + getDayOfWeek, } from "@internationalized/date"; import {I18nProvider, useLocale} from "@react-aria/i18n"; import {Button, ButtonGroup} from "@nextui-org/button"; import {Radio, RadioGroup} from "@nextui-org/radio"; import {cn} from "@nextui-org/theme"; -import {RangeCalendar, RangeCalendarProps} from "../src"; +import { + RangeCalendar, + RangeCalendarProps, + CalendarCellContent, + CalendarCellHeader, + CalendarCellBody, +} from "../src"; export default { title: "Components/RangeCalendar", @@ -75,7 +82,11 @@ const ControlledTemplate = (args: RangeCalendarProps) => { { + if (newValue) { + setValue(newValue); + } + }} {...args} color="secondary" /> @@ -156,7 +167,11 @@ const InvalidDatesTemplate = (args: RangeCalendarProps) => { errorMessage={isInvalid ? "Stay dates cannot fall on weekends" : undefined} isInvalid={isInvalid} value={date} - onChange={setDate} + onChange={(newValue) => { + if (newValue) { + setDate(newValue); + } + }} /> ); }; @@ -278,7 +293,11 @@ const PresetsTemplate = (args: RangeCalendarProps) => { } value={value} - onChange={setValue} + onChange={(newValue) => { + if (newValue) { + setValue(newValue); + } + }} onFocusChange={setFocusedValue} {...args} /> @@ -286,6 +305,72 @@ const PresetsTemplate = (args: RangeCalendarProps) => { ); }; +const CustomCellTemplate = (args: RangeCalendarProps) => { + const {locale} = useLocale(); + + return ( +
+
+ + {(date) => ( + + + +
+ {date.day % 7 === 0 && ( + + MTG + + )} + {date.day % 5 === 0 && ( + + MTG + + )} + {date.day % 3 === 0 && ( + + MTG + + )} +
+
+
+ )} +
+
+
+ + {(date) => { + const dayOfWeek = getDayOfWeek(date, locale); + const style = + dayOfWeek === 0 ? "text-red-500" : dayOfWeek === 6 ? "text-blue-500" : "inherit"; + + return ( + + + {date.day} + + + ); + }} + +
+
+ ); +}; + export const Default = { render: Template, args: { @@ -414,3 +499,10 @@ export const Presets = { ...defaultProps, }, }; + +export const CustomCellContent = { + render: CustomCellTemplate, + args: { + ...defaultProps, + }, +}; diff --git a/packages/core/theme/src/components/calendar.ts b/packages/core/theme/src/components/calendar.ts index 224002b373..7b8064b9fb 100644 --- a/packages/core/theme/src/components/calendar.ts +++ b/packages/core/theme/src/components/calendar.ts @@ -28,12 +28,15 @@ const calendar = tv({ grid: "w-full border-collapse z-0", gridHeader: "bg-content1 shadow-[0px_20px_20px_0px_rgb(0_0_0/0.05)]", gridHeaderRow: "px-4 pb-2 flex justify-center text-default-400", - gridHeaderCell: "flex w-8 justify-center items-center font-medium text-small", - gridBody: "", - gridBodyRow: "flex justify-center items-center first:mt-2", - cell: "py-0.5 px-0", - cellButton: [ - "w-8 h-8 flex items-center text-foreground justify-center rounded-full", + gridHeaderCell: "flex w-full justify-center items-center font-medium text-small", + gridBody: "w-full h-full", + gridBodyRow: "flex h-full px-4 justify-start items-start first:mt-2", + cell: "py-0.5 px-0 w-full h-full", + cellButton: ["relative w-full h-full justify-start", ...dataFocusVisibleClasses], + cellContent: "flex flex-col w-full gap-0.5 justify-start items-center ", + cellHeaderWrapper: ["flex w-full h-full justify-center items-center"], + cellHeader: [ + "w-8 h-8 flex items-center text-foreground justify-center rounded-full shrink-0", "box-border appearance-none select-none whitespace-nowrap font-normal", "subpixel-antialiased overflow-hidden tap-highlight-transparent", "data-[disabled=true]:text-default-300", @@ -45,6 +48,7 @@ const calendar = tv({ "data-[unavailable=true]:line-through", ...dataFocusVisibleClasses, ], + cellBody: "w-full h-full", pickerWrapper: "absolute inset-x-0 top-0 flex w-full h-[var(--picker-height)] justify-center opacity-0 pointer-events-none", pickerMonthList: "items-start", @@ -71,18 +75,22 @@ const calendar = tv({ // @internal isRange: { true: { - cellButton: [ + cellHeaderWrapper: [ // base "relative", "overflow-visible", // before pseudo element "before:content-[''] before:absolute before:inset-0 before:z-[-1] before:rounded-none", + "after:content-[''] after:absolute after:inset-0 after:z-[-1] after:rounded-none", // hide before pseudo element when the selected cell is outside the month "data-[outside-month=true]:before:hidden", "data-[selected=true]:data-[range-selection=true]:data-[outside-month=true]:bg-transparent", "data-[selected=true]:data-[range-selection=true]:data-[outside-month=true]:text-default-300", + "data-[outside-month=true]:after:hidden", + "data-[selected=true]:data-[range-selection=true]:data-[outside-month=true]:bg-transparent", + "data-[selected=true]:data-[range-selection=true]:data-[outside-month=true]:text-default-300", // middle // "data-[selected=true]:data-[range-selection=true]:bg-transparent", @@ -90,10 +98,26 @@ const calendar = tv({ // start (pseudo) "data-[range-start=true]:before:rounded-l-full", "data-[selection-start=true]:before:rounded-l-full", + "data-[range-start=true]:before:w-8", + "data-[selection-start=true]:before:w-8", + "data-[range-start=true]:before:justify-self-center", + "data-[selection-start=true]:before:justify-self-center", + "data-[range-start=true]:after:w-1/2", + "data-[selection-start=true]:after:w-1/2", + "data-[range-start=true]:after:justify-self-end", + "data-[selection-start=true]:after:justify-self-end", // end (pseudo) "data-[range-end=true]:before:rounded-r-full", "data-[selection-end=true]:before:rounded-r-full", + "data-[range-end=true]:before:w-8", + "data-[selection-end=true]:before:w-8", + "data-[range-end=true]:before:justify-self-center", + "data-[selection-end=true]:before:justify-self-center", + "data-[range-end=true]:after:w-1/2", + "data-[selection-end=true]:after:w-1/2", + "data-[range-end=true]:after:justify-self-start", + "data-[selection-end=true]:after:justify-self-start", // start (selected) "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:rounded-full", @@ -106,7 +130,7 @@ const calendar = tv({ }, hideDisabledDates: { true: { - cellButton: "data-[disabled=true]:data-[outside-month=true]:opacity-0", + cellHeader: "data-[disabled=true]:data-[outside-month=true]:opacity-0", }, false: {}, }, @@ -130,20 +154,20 @@ const calendar = tv({ }, showShadow: { true: { - cellButton: "data-[selected=true]:shadow-md", + cellHeader: "data-[selected=true]:shadow-md", }, false: { - cellButton: "shadow-none data-[selected=true]:shadow-none", + cellHeader: "shadow-none data-[selected=true]:shadow-none", }, }, disableAnimation: { true: { - cellButton: "transition-none", + cellHeader: "transition-none", }, false: { headerWrapper: ["[&_.chevron-icon]:transition-transform", "after:transition-height"], grid: "transition-opacity", - cellButton: ["origin-center transition-[transform,background-color,color] !duration-150"], + cellHeader: ["origin-center transition-[transform,background-color,color] !duration-150"], pickerWrapper: "transition-opacity !duration-250", pickerItem: "transition-opacity", }, @@ -161,7 +185,7 @@ const calendar = tv({ isRange: false, color: "foreground", class: { - cellButton: [ + cellHeader: [ "data-[hover=true]:bg-default-200", "data-[selected=true]:bg-foreground", "data-[selected=true]:text-background", @@ -176,7 +200,7 @@ const calendar = tv({ isRange: false, color: "primary", class: { - cellButton: [ + cellHeader: [ "data-[selected=true]:bg-primary", "data-[selected=true]:text-primary-foreground", "data-[hover=true]:bg-primary-50", @@ -190,7 +214,7 @@ const calendar = tv({ isRange: false, color: "secondary", class: { - cellButton: [ + cellHeader: [ "data-[selected=true]:bg-secondary", "data-[selected=true]:text-secondary-foreground", "data-[hover=true]:bg-secondary-50", @@ -204,7 +228,7 @@ const calendar = tv({ isRange: false, color: "success", class: { - cellButton: [ + cellHeader: [ "data-[selected=true]:bg-success", "data-[selected=true]:text-success-foreground", "data-[hover=true]:bg-success-100", @@ -222,7 +246,7 @@ const calendar = tv({ isRange: false, color: "warning", class: { - cellButton: [ + cellHeader: [ "data-[selected=true]:bg-warning", "data-[selected=true]:text-warning-foreground", "data-[hover=true]:bg-warning-100", @@ -240,7 +264,7 @@ const calendar = tv({ isRange: false, color: "danger", class: { - cellButton: [ + cellHeader: [ "data-[selected=true]:bg-danger", "data-[selected=true]:text-danger-foreground", "data-[hover=true]:bg-danger-100", @@ -259,11 +283,13 @@ const calendar = tv({ isRange: true, color: "foreground", class: { - cellButton: [ + cellHeaderWrapper: [ // middle "data-[selected=true]:data-[range-selection=true]:before:bg-foreground/10", "data-[selected=true]:data-[range-selection=true]:text-foreground", - + "data-[selected=true]:data-[range-selection=true]:after:bg-foreground/10", + ], + cellHeader: [ // start (selected) "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-foreground", "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-background", @@ -278,11 +304,13 @@ const calendar = tv({ isRange: true, color: "primary", class: { - cellButton: [ + cellHeaderWrapper: [ // middle "data-[selected=true]:data-[range-selection=true]:before:bg-primary-50", "data-[selected=true]:data-[range-selection=true]:text-primary", - + "data-[selected=true]:data-[range-selection=true]:after:bg-primary-50", + ], + cellHeader: [ // start (selected) "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-primary", "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-primary-foreground", @@ -297,11 +325,13 @@ const calendar = tv({ isRange: true, color: "secondary", class: { - cellButton: [ + cellHeaderWrapper: [ // middle "data-[selected=true]:data-[range-selection=true]:before:bg-secondary-50", "data-[selected=true]:data-[range-selection=true]:text-secondary", - + "data-[selected=true]:data-[range-selection=true]:after:bg-secondary-50", + ], + cellHeader: [ // start (selected) "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-secondary", "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-secondary-foreground", @@ -316,13 +346,16 @@ const calendar = tv({ isRange: true, color: "success", class: { - cellButton: [ + cellHeaderWrapper: [ // middle "data-[selected=true]:data-[range-selection=true]:before:bg-success-100", "data-[selected=true]:data-[range-selection=true]:text-success-600", "dark:data-[selected=true]:data-[range-selection=true]:before:bg-success-50", "dark:data-[selected=true]:data-[range-selection=true]:text-success-500", - + "data-[selected=true]:data-[range-selection=true]:after:bg-success-100", + "dark:data-[selected=true]:data-[range-selection=true]:after:bg-success-50", + ], + cellHeader: [ // start (selected) "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-success", "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-success-foreground", @@ -339,12 +372,15 @@ const calendar = tv({ isRange: true, color: "warning", class: { - cellButton: [ + cellHeaderWrapper: [ // middle "data-[selected=true]:data-[range-selection=true]:before:bg-warning-100", "dark:data-[selected=true]:data-[range-selection=true]:before:bg-warning-50", "data-[selected=true]:data-[range-selection=true]:text-warning-500", - + "data-[selected=true]:data-[range-selection=true]:after:bg-warning-100", + "dark:data-[selected=true]:data-[range-selection=true]:after:bg-warning-50", + ], + cellHeader: [ // start (selected) "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-warning", "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-warning-foreground", @@ -359,11 +395,13 @@ const calendar = tv({ isRange: true, color: "danger", class: { - cellButton: [ + cellHeaderWrapper: [ // middle "data-[selected=true]:data-[range-selection=true]:before:bg-danger-50", "data-[selected=true]:data-[range-selection=true]:text-danger-500", - + "data-[selected=true]:data-[range-selection=true]:after:bg-danger-50", + ], + cellHeader: [ // start (selected) "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-danger", "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-danger-foreground", @@ -379,42 +417,42 @@ const calendar = tv({ showShadow: true, color: "foreground", class: { - cellButton: "data-[selected=true]:shadow-foreground/40", + cellHeader: "data-[selected=true]:shadow-foreground/40", }, }, { showShadow: true, color: "primary", class: { - cellButton: "data-[selected=true]:shadow-primary/40", + cellHeader: "data-[selected=true]:shadow-primary/40", }, }, { showShadow: true, color: "secondary", class: { - cellButton: "data-[selected=true]:shadow-secondary/40", + cellHeader: "data-[selected=true]:shadow-secondary/40", }, }, { showShadow: true, color: "success", class: { - cellButton: "data-[selected=true]:shadow-success/40", + cellHeader: "data-[selected=true]:shadow-success/40", }, }, { showShadow: true, color: "warning", class: { - cellButton: "data-[selected=true]:shadow-warning/40", + cellHeader: "data-[selected=true]:shadow-warning/40", }, }, { showShadow: true, color: "danger", class: { - cellButton: "data-[selected=true]:shadow-danger/40", + cellHeader: "data-[selected=true]:shadow-danger/40", }, }, // showShadow & isRange @@ -422,7 +460,7 @@ const calendar = tv({ showShadow: true, isRange: true, class: { - cellButton: [ + cellHeader: [ // remove shadow from middle "data-[selected=true]:shadow-none", // add shadow to start (selected)