Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(calendar): add custom cell content to Calendar #3554

Open
wants to merge 30 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a24b443
feat(calendar): add custom cell content
1amageek Jun 24, 2024
487be5e
refactor(calendar-cell): add useRef hook to improve performance
1amageek Jun 24, 2024
7a0fa5c
refactor: improve CalendarCell by destructuring props and adding type…
1amageek Jun 25, 2024
a449568
test(calendar): add test for custom cell content in Calendar and Rang…
1amageek Jun 25, 2024
0218de1
chore: add changeset for custom cell content feature in Calendar and …
1amageek Jun 25, 2024
4d40ddd
refactor: use shared template component for custom cell content in ca…
1amageek Jun 29, 2024
c0f2b34
feat: add calendar width and custom cell templates for calendar and r…
1amageek Jul 25, 2024
448ab55
feat(calendar): add renderCellContent prop for custom cell content
1amageek Jul 25, 2024
9445d2b
feat(calendar): move renderCellContent to context
1amageek Nov 22, 2024
930d4af
feat(calendar): add cell components and context management
1amageek Nov 23, 2024
9fc7840
feat(calendar): implement custom cell content rendering
1amageek Nov 23, 2024
b327e40
feat(docs): add custom cell content examples for calendar components
1amageek Nov 23, 2024
b2fcf12
fix(theme): correct typo in calendar cellBody height class
1amageek Nov 23, 2024
39dd82c
Merge branch 'canary' into canary
1amageek Nov 23, 2024
0f22c26
feat(calendar): add day of week calculation to CalendarCell component
1amageek Nov 23, 2024
2ae85a9
refactor(calendar): improve calendar cell components and remove unuse…
1amageek Nov 23, 2024
01b3ba2
feat(calendar): update components to use CalendarCellHeader
1amageek Nov 23, 2024
874af0b
refactor(theme): update gridBodyRow styles for better spacing
1amageek Nov 23, 2024
e814634
refactor(calendar): integrate CalendarCellHeader into calendar cell c…
1amageek Nov 23, 2024
24ccded
feat(calendar): add support for custom cell content in calendar compo…
1amageek Nov 23, 2024
1cd1d21
feat(calendar): add role and aria-label to birthday events
1amageek Nov 27, 2024
8902936
feat(calendar): add calendar cell components and context support
1amageek Dec 7, 2024
84a85d7
feat(calendar): add calendar cell components and context support
1amageek Dec 7, 2024
deb9dfc
ci(changesets): version packages
jrgarciadev Dec 7, 2024
7dd0840
Merge pull request #1 from 1amageek/changeset-release/canary
1amageek Dec 7, 2024
60892c4
Add support for custom cell content in Calendar components
1amageek Dec 7, 2024
5e2da7c
Merge branch 'canary' into canary
1amageek Dec 7, 2024
215eb51
Merge branch 'canary' of https://github.com/nextui-org/nextui into ca…
1amageek Dec 12, 2024
fdc5337
Merge branch 'canary' into canary
1amageek Dec 24, 2024
81d0422
Update packages/components/calendar/src/calendar-cell-header.tsx
1amageek Dec 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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", () => {
1amageek marked this conversation as resolved.
Show resolved Hide resolved
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";
1amageek marked this conversation as resolved.
Show resolved Hide resolved
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;
1amageek marked this conversation as resolved.
Show resolved Hide resolved
}

export function CalendarBase(props: CalendarBaseProps) {
Expand All @@ -47,6 +49,7 @@ export function CalendarBase(props: CalendarBaseProps) {
errorMessageProps,
calendarRef: ref,
errorMessage,
renderCellContent,
1amageek marked this conversation as resolved.
Show resolved Hide resolved
...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}
1amageek marked this conversation as resolved.
Show resolved Hide resolved
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;
1amageek marked this conversation as resolved.
Show resolved Hide resolved
}

export function CalendarMonth(props: CalendarMonthProps) {
const {startDate, direction, currentMonth} = props;
const {startDate, direction, currentMonth, renderCellContent} = props;
1amageek marked this conversation as resolved.
Show resolved Hide resolved

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}
1amageek marked this conversation as resolved.
Show resolved Hide resolved
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;
}
1amageek marked this conversation as resolved.
Show resolved Hide resolved

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;
}
1amageek marked this conversation as resolved.
Show resolved Hide resolved

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;
1amageek marked this conversation as resolved.
Show resolved Hide resolved
}

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

export function useCalendar<T extends DateValue>({
buttonPickerProps: buttonPickerPropsProp,
className,
renderCellContent,
1amageek marked this conversation as resolved.
Show resolved Hide resolved
...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,
1amageek marked this conversation as resolved.
Show resolved Hide resolved
...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;
}
1amageek marked this conversation as resolved.
Show resolved Hide resolved

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