Skip to content

Commit

Permalink
feat(calendar): add renderCellContent prop for custom cell content
Browse files Browse the repository at this point in the history
  • Loading branch information
1amageek committed Jul 25, 2024
2 parents 6d9995b + c0f2b34 commit 448ab55
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/popular-seals-appear.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions packages/components/calendar/__tests__/calendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,4 +464,27 @@ describe("Calendar", () => {
expect(year).toHaveAttribute("data-value", "2567");
});
});
describe("Custom cell content", () => {
it("should render custom content in the calendar cells", () => {
const renderCellContent = (date: CalendarDate) => (
<div>
{date.day}
<span>*</span>
</div>
);

const wrapper = render(
<Calendar
defaultValue={new CalendarDate(2024, 3, 31)}
renderCellContent={renderCellContent}
/>,
);

const gridCells = wrapper.getAllByRole("gridcell");
const customContentCell = gridCells.find((cell) => cell.textContent === "31*");

expect(customContentCell).not.toBeNull();
expect(customContentCell).toHaveTextContent("31*");
});
});
});
27 changes: 27 additions & 0 deletions packages/components/calendar/__tests__/range-calendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -748,4 +748,31 @@ 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 renderCellContent = (date: CalendarDate) => (
<div>
{date.day}
<span>*</span>
</div>
);

const wrapper = render(
<RangeCalendar
defaultValue={{start: new CalendarDate(2024, 6, 25), end: new CalendarDate(2024, 6, 26)}}
renderCellContent={renderCellContent}
/>,
);

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*");
});
});
});
4 changes: 4 additions & 0 deletions packages/components/calendar/src/calendar-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {As, HTMLNextUIProps} from "@nextui-org/system";
import type {ButtonProps} from "@nextui-org/button";
import type {HTMLAttributes, ReactNode, RefObject} from "react";

import {CalendarDate} from "@internationalized/date";
import {Fragment} from "react";
import {useState} from "react";
import {useLocale} from "@react-aria/i18n";
Expand Down Expand Up @@ -32,6 +33,7 @@ export interface CalendarBaseProps extends HTMLNextUIProps<"div"> {
errorMessageProps: HTMLAttributes<HTMLElement>;
calendarRef: RefObject<HTMLDivElement>;
errorMessage?: ReactNode;
renderCellContent?: (date: CalendarDate) => React.ReactNode;
}

export function CalendarBase(props: CalendarBaseProps) {
Expand All @@ -47,6 +49,7 @@ export function CalendarBase(props: CalendarBaseProps) {
errorMessageProps,
calendarRef: ref,
errorMessage,
renderCellContent,
...otherProps
} = props;

Expand Down Expand Up @@ -98,6 +101,7 @@ export function CalendarBase(props: CalendarBaseProps) {
key={`calendar-month-${i}`}
currentMonth={currentMonth.month}
direction={direction}
renderCellContent={renderCellContent}
startDate={d}
/>
);
Expand Down
45 changes: 27 additions & 18 deletions packages/components/calendar/src/calendar-cell.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type {CalendarState, RangeCalendarState} from "@react-stately/calendar";
import type {CalendarSlots, SlotsToClasses, CalendarReturnType} from "@nextui-org/theme";

import React, {useRef} from "react";
import {CalendarDate, getDayOfWeek, isSameDay, isSameMonth, isToday} from "@internationalized/date";
import {AriaCalendarCellProps, useCalendarCell} from "@react-aria/calendar";
import {HTMLNextUIProps} from "@nextui-org/system";
import {mergeProps} from "@react-aria/utils";
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";

export interface CalendarCellProps extends HTMLNextUIProps<"td">, AriaCalendarCellProps {
Expand All @@ -17,12 +17,21 @@ export interface CalendarCellProps extends HTMLNextUIProps<"td">, AriaCalendarCe
slots?: CalendarReturnType;
classNames?: SlotsToClasses<CalendarSlots>;
currentMonth: CalendarDate;
renderCellContent?: (date: CalendarDate) => React.ReactNode;
}

export function CalendarCell(originalProps: CalendarCellProps) {
const {state, slots, isPickerVisible, currentMonth, classNames, ...props} = originalProps;
const {
state,
slots,
isPickerVisible,
currentMonth,
classNames,
renderCellContent,
...otherProps
} = originalProps;

const ref = useRef<HTMLButtonElement>(null);
const ref = useRef<HTMLDivElement>(null);

const {
cellProps,
Expand All @@ -35,32 +44,32 @@ export function CalendarCell(originalProps: CalendarCellProps) {
formattedDate,
} = useCalendarCell(
{
...props,
isDisabled: !isSameMonth(props.date, currentMonth) || isPickerVisible,
...otherProps,
isDisabled: !isSameMonth(otherProps.date, currentMonth) || isPickerVisible,
},
state,
ref,
);

const isUnavailable = state.isCellUnavailable(props.date);
const isUnavailable = state.isCellUnavailable(otherProps.date);
const isLastSelectedBeforeDisabled =
!isDisabled && !isInvalid && state.isCellUnavailable(props.date.add({days: 1}));
!isDisabled && !isInvalid && state.isCellUnavailable(otherProps.date.add({days: 1}));
const isFirstSelectedAfterDisabled =
!isDisabled && !isInvalid && state.isCellUnavailable(props.date.subtract({days: 1}));
!isDisabled && !isInvalid && state.isCellUnavailable(otherProps.date.subtract({days: 1}));
const highlightedRange = "highlightedRange" in state && state.highlightedRange;
const isSelectionStart =
isSelected && highlightedRange && isSameDay(props.date, highlightedRange.start);
isSelected && highlightedRange && isSameDay(otherProps.date, highlightedRange.start);
const isSelectionEnd =
isSelected && highlightedRange && isSameDay(props.date, highlightedRange.end);
isSelected && highlightedRange && isSameDay(otherProps.date, highlightedRange.end);
const {locale} = useLocale();
const dayOfWeek = getDayOfWeek(props.date, locale);
const dayOfWeek = getDayOfWeek(otherProps.date, locale);
const isRangeStart =
isSelected && (isFirstSelectedAfterDisabled || dayOfWeek === 0 || props.date.day === 1);
isSelected && (isFirstSelectedAfterDisabled || dayOfWeek === 0 || otherProps.date.day === 1);
const isRangeEnd =
isSelected &&
(isLastSelectedBeforeDisabled ||
dayOfWeek === 6 ||
props.date.day === currentMonth.calendar.getDaysInMonth(currentMonth));
otherProps.date.day === currentMonth.calendar.getDaysInMonth(currentMonth));

const {focusProps, isFocusVisible} = useFocusRing();
const {hoverProps, isHovered} = useHover({
Expand All @@ -69,15 +78,15 @@ export function CalendarCell(originalProps: CalendarCellProps) {

return (
<td className={slots?.cell({class: classNames?.cell})} data-slot="cell" {...cellProps}>
<span
<div
{...mergeProps(buttonProps, hoverProps, focusProps)}
ref={ref}
className={slots?.cellButton({class: classNames?.cellButton})}
data-disabled={dataAttr(isDisabled && !isInvalid)}
data-focus-visible={dataAttr(isFocused && isFocusVisible)}
data-hover={dataAttr(isHovered)}
data-invalid={dataAttr(isInvalid)}
data-outside-month={dataAttr(!isSameMonth(props.date, currentMonth))}
data-outside-month={dataAttr(!isSameMonth(otherProps.date, currentMonth))}
data-pressed={dataAttr(isPressed && !state.isReadOnly)}
data-range-end={dataAttr(isRangeEnd)}
data-range-selection={dataAttr(isSelected && "highlightedRange" in state)}
Expand All @@ -86,11 +95,11 @@ export function CalendarCell(originalProps: CalendarCellProps) {
data-selected={dataAttr(isSelected)}
data-selection-end={dataAttr(isSelectionEnd)}
data-selection-start={dataAttr(isSelectionStart)}
data-today={dataAttr(isToday(props.date, state.timeZone))}
data-today={dataAttr(isToday(otherProps.date, state.timeZone))}
data-unavailable={dataAttr(isUnavailable)}
>
<span>{formattedDate}</span>
</span>
{renderCellContent ? renderCellContent(otherProps.date) : <span>{formattedDate}</span>}
</div>
</td>
);
}
4 changes: 3 additions & 1 deletion packages/components/calendar/src/calendar-month.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ export interface CalendarMonthProps extends HTMLNextUIProps<"table">, CalendarPr
startDate: CalendarDate;
currentMonth: number;
direction: number;
renderCellContent?: (date: CalendarDate) => React.ReactNode;
}

export function CalendarMonth(props: CalendarMonthProps) {
const {startDate, direction, currentMonth} = props;
const {startDate, direction, currentMonth, renderCellContent} = props;

const {locale} = useLocale();
const weeksInMonth = getWeeksInMonth(startDate, locale);
Expand Down Expand Up @@ -54,6 +55,7 @@ export function CalendarMonth(props: CalendarMonthProps) {
currentMonth={startDate}
date={date}
isPickerVisible={isHeaderExpanded}
renderCellContent={renderCellContent}
slots={slots}
state={state}
/>
Expand Down
6 changes: 4 additions & 2 deletions packages/components/calendar/src/calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {DateValue} from "@internationalized/date";
import type {DateValue, CalendarDate} from "@internationalized/date";
import type {ForwardedRef, ReactElement, Ref} from "react";

import {forwardRef} from "@nextui-org/system";
Expand All @@ -7,7 +7,9 @@ import {UseCalendarProps, useCalendar} from "./use-calendar";
import {CalendarProvider} from "./calendar-context";
import {CalendarBase} from "./calendar-base";

interface Props<T extends DateValue> extends Omit<UseCalendarProps<T>, "isHeaderWrapperExpanded"> {}
interface Props<T extends DateValue> extends Omit<UseCalendarProps<T>, "isHeaderWrapperExpanded"> {
renderCellContent?: (date: CalendarDate) => React.ReactNode;
}

function Calendar<T extends DateValue>(props: Props<T>, ref: ForwardedRef<HTMLDivElement>) {
const {context, getBaseCalendarProps} = useCalendar<T>({...props, ref});
Expand Down
6 changes: 4 additions & 2 deletions packages/components/calendar/src/range-calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {DateValue} from "@internationalized/date";
import type {DateValue, CalendarDate} from "@internationalized/date";
import type {ForwardedRef, ReactElement, Ref} from "react";

import {forwardRef} from "@nextui-org/system";
Expand All @@ -14,7 +14,9 @@ interface Props<T extends DateValue>
| "onHeaderExpandedChange"
| "isHeaderWrapperExpanded"
| "showMonthAndYearPickers"
> {}
> {
renderCellContent?: (date: CalendarDate) => React.ReactNode;
}

function RangeCalendar<T extends DateValue>(props: Props<T>, ref: ForwardedRef<HTMLDivElement>) {
const {context, getBaseCalendarProps} = useRangeCalendar<T>({...props, ref});
Expand Down
5 changes: 4 additions & 1 deletion packages/components/calendar/src/use-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {useMemo, useRef} from "react";
import {filterDOMProps} from "@nextui-org/react-utils";
import {useCalendar as useAriaCalendar} from "@react-aria/calendar";
import {useCalendarState} from "@react-stately/calendar";
import {createCalendar} from "@internationalized/date";
import {createCalendar, CalendarDate} from "@internationalized/date";
import {clsx} from "@nextui-org/shared-utils";
import {chain, mergeProps} from "@react-aria/utils";

Expand All @@ -18,13 +18,15 @@ interface Props extends UseCalendarBaseProps {
* Props for the button picker, which is used to select the month, year and expand the header.
*/
buttonPickerProps?: ButtonProps;
renderCellContent?: (date: CalendarDate) => React.ReactNode;
}

export type UseCalendarProps<T extends DateValue> = Props & AriaCalendarProps<T>;

export function useCalendar<T extends DateValue>({
buttonPickerProps: buttonPickerPropsProp,
className,
renderCellContent,
...originalProps
}: UseCalendarProps<T>) {
const {
Expand Down Expand Up @@ -95,6 +97,7 @@ export function useCalendar<T extends DateValue>({
errorMessageProps: getErrorMessageProps(errorMessageProps),
className: slots.base({class: baseStyles}),
errorMessage,
renderCellContent,
...filterDOMProps(otherProps, {
enabled: shouldFilterDOMProps,
}),
Expand Down
8 changes: 6 additions & 2 deletions packages/components/calendar/src/use-range-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {useMemo, useRef} from "react";
import {filterDOMProps} from "@nextui-org/react-utils";
import {useRangeCalendar as useAriaRangeCalendar} from "@react-aria/calendar";
import {useRangeCalendarState} from "@react-stately/calendar";
import {createCalendar} from "@internationalized/date";
import {createCalendar, CalendarDate} from "@internationalized/date";
import {clsx} from "@nextui-org/shared-utils";

import {ContextType, useCalendarBase, UseCalendarBaseProps} from "./use-calendar-base";
Expand All @@ -17,12 +17,15 @@ type NextUIBaseProps<T extends DateValue> = Omit<
keyof AriaRangeCalendarProps<T>
>;

interface Props<T extends DateValue> extends UseCalendarBaseProps, NextUIBaseProps<T> {}
interface Props<T extends DateValue> extends UseCalendarBaseProps, NextUIBaseProps<T> {
renderCellContent?: (date: CalendarDate) => React.ReactNode;
}

export type UseRangeCalendarProps<T extends DateValue> = Props<T> & AriaRangeCalendarProps<T>;

export function useRangeCalendar<T extends DateValue>({
className,
renderCellContent,
...originalProps
}: UseRangeCalendarProps<T>) {
const {
Expand Down Expand Up @@ -86,6 +89,7 @@ export function useRangeCalendar<T extends DateValue>({
errorMessageProps: getErrorMessageProps(errorMessageProps),
className: slots.base({class: baseStyles}),
errorMessage,
renderCellContent,
...filterDOMProps(otherProps, {
enabled: shouldFilterDOMProps,
}),
Expand Down
42 changes: 42 additions & 0 deletions packages/components/calendar/stories/calendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,35 @@ const CalendarWidthTemplate = (args: CalendarProps) => {
);
};

const CustomCellTemplate = (args: CalendarProps) => {
return (
<div className="flex gap-4">
<div className="flex flex-col items-center gap-4">
<Calendar
{...args}
calendarWidth={300}
classNames={{
gridHeaderCell: "w-12",
cell: "h-12",
cellButton: "w-12 h-full rounded-xl",
}}
/>
</div>
<div className="flex flex-col items-center gap-4">
<Calendar
{...args}
calendarWidth="30em"
classNames={{
gridHeaderCell: "w-16",
cell: "w-16",
cellButton: "w-16 h-full rounded-xl",
}}
/>
</div>
</div>
);
};

export const Default = {
render: Template,
args: {
Expand Down Expand Up @@ -375,3 +404,16 @@ export const CalendarWidth = {
...defaultProps,
},
};

export const CustomCellContent = {
render: CustomCellTemplate,
args: {
...defaultProps,
renderCellContent: (date) => (
<div className="flex flex-col gap-0">
{date.day}
<span className="text-tiny text-default-500 text-center"></span>
</div>
),
},
};
Loading

0 comments on commit 448ab55

Please sign in to comment.