diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index c5d95758..5eb06a1d 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -4,6 +4,7 @@ import { Avatar, AvatarGroup, Button, + Calendar, Callout, DatePicker, Dialog, @@ -45,7 +46,7 @@ const Page = () => { const [selectValue1, setSelectValue1] = useState(''); const [selectValue2, setSelectValue2] = useState(''); const [inputValue, setInputValue] = useState(''); - const [rangeValue, setRangeValue] = useState({ + const [rangeValue, setRangeValue] = useState({ from: dayjs('2027-11-15').toDate(), to: dayjs('2027-12-10').toDate() }); @@ -165,7 +166,6 @@ const Page = () => { onClear={() => setSearch1('')} /> console.log(value)} @@ -187,10 +187,14 @@ const Page = () => { /> setRangeValue(range)} + onSelect={range => + setRangeValue({ + from: range.from ?? new Date(), + to: range.to ?? new Date() + }) + } calendarProps={{ captionLayout: 'dropdown', mode: 'range', @@ -233,6 +237,143 @@ const Page = () => { }} /> + + Calendar with Date Info (Object) + + + + + + 25% + + + ), + [dayjs().add(5, 'day').format('DD-MM-YYYY')]: ( + + + + 25% + + + ), + [dayjs().add(10, 'day').format('DD-MM-YYYY')]: ( + + + + 25% + + + ) + }} + /> + + + Calendar with Date Info (Function) + + + { + const today = new Date(); + const isToday = + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear(); + + // Show info on Sundays + if (date.getDay() === 0) { + return ( + + + + Sun + + + ); + } + + // Show info on 15th of any month + if (date.getDate() === 15) { + return ( + + + + 15th + + + ); + } + + // Show info for today + if (isToday) { + return ( + + + + Today + + + ); + } + + return null; + }} + /> + `, + name: 'Calendar', + code: `` }, { - name: "Range Picker", + name: 'Range Picker', code: ` - `, + ` }, { - name: "Date Picker", + name: 'Date Picker', code: ` - `, - }, - ], + ` + } + ] }; export const calendarDemo = { - type: "code", + type: 'code', tabs: [ { - name: "Basic", - code: ``, + name: 'Basic', + code: `` }, { - name: "With Loading", - code: ``, - }, - ], + name: 'With Loading', + code: `` + } + ] }; export const rangePickerDemo = { - type: "code", + type: 'code', tabs: [ { - name: "Basic", - code: ``, + name: 'Basic', + code: `` }, { - name: "Without Calendar Icon", - code: ``, + name: 'Without Calendar Icon', + code: `` }, { - name: "Custom Trigger", + name: 'Custom Trigger', code: ` )} - `, - }, - ], + ` + } + ] }; export const datePickerDemo = { - type: "code", + type: 'code', tabs: [ { - name: "Basic", - code: ``, + name: 'Basic', + code: `` }, { - name: "Without Calendar Icon", - code: ``, + name: 'Without Calendar Icon', + code: `` }, { - name: "Custom Trigger", + name: 'Custom Trigger', code: ` {({ selectedDate }) => ( @@ -96,7 +96,32 @@ export const datePickerDemo = { Selected: {selectedDate} )} - `, - }, - ], + ` + } + ] +}; + +export const dateInfoDemo = { + type: 'code', + tabs: [ + { + name: 'With Date Info', + code: ` + + + 25% + + ) + }} +/>` + } + ] }; diff --git a/apps/www/src/content/docs/components/calendar/index.mdx b/apps/www/src/content/docs/components/calendar/index.mdx index b47d7a9c..694ac208 100644 --- a/apps/www/src/content/docs/components/calendar/index.mdx +++ b/apps/www/src/content/docs/components/calendar/index.mdx @@ -8,6 +8,7 @@ import { datePickerDemo, rangePickerDemo, calendarDemo, + dateInfoDemo, } from "./demo.ts"; @@ -42,6 +43,12 @@ Choose between different variants to convey different meanings or importance lev +#### Custom Date Information + +You can display custom components above each date using the `dateInfo` prop. The keys should be date strings in `"dd-MM-yyyy"` format, and the values are React components that will be rendered above the date number. + + + ### Range Picker The Range Picker component allows selecting a date range with the following behaviors: diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index 342b8dba..53c38428 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -11,8 +11,24 @@ export interface CalendarProps { /** Boolean to show loading state */ loadingData?: boolean; - /** Object containing date-specific information like icons and text */ - dateInfo?: Record; + /** + * Custom React components to render above each date. + * Can be either: + * - An object with date strings in "dd-MM-yyyy" format as keys + * - A function that receives a Date and returns a ReactNode or null + * The component will be rendered above the date number. + * + * @example + * // Object approach (static data) + * dateInfo={{ "15-01-2024":
17%
}} + * + * @example + * // Function approach (dynamic logic) + * dateInfo={(date) => date.getDay() === 0 ?
Sunday
: null} + */ + dateInfo?: + | Record + | ((date: Date) => React.ReactNode | null); /** Boolean to show days from previous/next months */ showOutsideDays?: boolean; diff --git a/packages/raystack/components/badge/badge.module.css b/packages/raystack/components/badge/badge.module.css index ae0ecbd0..63c46ee4 100644 --- a/packages/raystack/components/badge/badge.module.css +++ b/packages/raystack/components/badge/badge.module.css @@ -59,11 +59,7 @@ } .badge-gradient { - background: linear-gradient( - to right, - #AD00E933 0%, - #EF040433 100% - ); + background: linear-gradient(to right, #ad00e933 0%, #ef040433 100%); color: var(--rs-color-foreground-base-primary); } diff --git a/packages/raystack/components/calendar/__tests__/calendar.test.tsx b/packages/raystack/components/calendar/__tests__/calendar.test.tsx index 34dfdb1c..7cf7b142 100644 --- a/packages/raystack/components/calendar/__tests__/calendar.test.tsx +++ b/packages/raystack/components/calendar/__tests__/calendar.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import dayjs from 'dayjs'; import { describe, expect, it, vi } from 'vitest'; import { Calendar } from '../calendar'; import styles from '../calendar.module.css'; @@ -110,6 +111,134 @@ describe('Calendar', () => { }); }); + describe('DateInfo Support', () => { + it('renders custom component for date with dateInfo', () => { + const TestComponent = () => ( +
Custom Info
+ ); + const today = dayjs().format('DD-MM-YYYY'); + const { container } = render( + + }} + /> + ); + + // The component should be rendered in the calendar + expect( + container.querySelector('[data-testid="custom-date-info"]') + ).toBeInTheDocument(); + }); + + it('applies day_button_with_info class when dateInfo is present', () => { + const today = dayjs().format('DD-MM-YYYY'); + const { container } = render( + Info + }} + /> + ); + + const dayWithInfo = container.querySelector( + `.${styles.dayButtonWithInfo}` + ); + expect(dayWithInfo).toBeInTheDocument(); + }); + + it('does not render dateInfo for dates not in the dateInfo object', () => { + const today = dayjs().format('DD-MM-YYYY'); + const { container } = render( + Info + }} + /> + ); + + // Should only have one date with info + const dateInfos = container.querySelectorAll('[data-testid="date-info"]'); + expect(dateInfos.length).toBeGreaterThanOrEqual(0); + }); + + it('handles multiple dates with dateInfo', () => { + const today = dayjs().format('DD-MM-YYYY'); + const tomorrow = dayjs().add(1, 'day').format('DD-MM-YYYY'); + const { container } = render( + Info 1, + [tomorrow]:
Info 2
+ }} + /> + ); + + const info1 = container.querySelector('[data-testid="info-1"]'); + const info2 = container.querySelector('[data-testid="info-2"]'); + + // At least one should be present (depending on which month is visible) + expect(info1 || info2).toBeTruthy(); + }); + + it('supports function-based dateInfo', () => { + const { container } = render( + { + // Show info only on Sundays + if (date.getDay() === 0) { + return
Sunday
; + } + return null; + }} + /> + ); + + // Should render for Sundays if any are visible in current month + // The querySelector will return null if not found, which is fine + const sundayInfo = container.querySelector('[data-testid="sunday-info"]'); + // Test passes if function approach works (may or may not find Sunday depending on month) + expect(container).toBeInTheDocument(); + }); + + it('supports function-based dateInfo with date logic', () => { + const today = new Date(); + const { container } = render( + { + // Show info only for today + if ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ) { + return
Today
; + } + return null; + }} + /> + ); + + expect( + container.querySelector('[data-testid="today-info"]') + ).toBeInTheDocument(); + }); + + it('renders date number even when dateInfo is present', () => { + const today = dayjs().format('DD-MM-YYYY'); + const { container } = render( + Info + }} + /> + ); + + const dayNumber = container.querySelector(`.${styles.dayNumber}`); + expect(dayNumber).toBeInTheDocument(); + }); + }); + describe('Accessibility', () => { it('has correct ARIA roles', () => { render(); diff --git a/packages/raystack/components/calendar/calendar.module.css b/packages/raystack/components/calendar/calendar.module.css index bcc09134..759105ff 100644 --- a/packages/raystack/components/calendar/calendar.module.css +++ b/packages/raystack/components/calendar/calendar.module.css @@ -6,7 +6,7 @@ min-height: 346px; } -.caption_label, +.captionLabel, .dropdowns > span, .dropdown_trigger { font-weight: var(--rs-font-weight-medium); @@ -19,11 +19,11 @@ font-style: normal; } -.caption_label[aria-hidden="true"] { +.captionLabel[aria-hidden="true"] { display: none; } -.nav_button { +.navButton { position: absolute; top: 0; border: 0px; @@ -33,16 +33,16 @@ height: var(--rs-space-7); } -.nav_button:disabled { +.navButton:disabled { opacity: 0.5; color: var(--rs-color-foreground-base-tertiary); } -.nav_button_previous { +.navButtonPrevious { left: 0; } -.nav_button_next { +.navButtonNext { right: 0; } @@ -57,7 +57,7 @@ width: 100%; } -.month_caption { +.monthCaption { display: flex; justify-content: center; align-items: center; @@ -95,7 +95,7 @@ background-color: transparent; } -.selected:not(.range_middle) { +.selected:not(.rangeMiddle) { width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); border-radius: var(--rs-radius-5); @@ -106,7 +106,7 @@ } } -.range_middle { +.rangeMiddle { border-radius: 0; background-color: var(--rs-color-background-base-primary-hover); margin-bottom: var(--rs-space-1); @@ -116,22 +116,22 @@ } } -.range_middle:first-of-type { +.rangeMiddle:first-of-type { border-top-left-radius: var(--rs-radius-5); border-bottom-left-radius: var(--rs-radius-5); } -.range_middle:last-of-type { +.rangeMiddle:last-of-type { border-top-right-radius: var(--rs-radius-5); border-bottom-right-radius: var(--rs-radius-5); } -.range_start:not(.range_end) { +.rangeStart:not(.rangeEnd) { border-top-right-radius: 0; border-bottom-right-radius: 0; } -.range_end:not(.range_start) { +.rangeEnd:not(.rangeStart) { border-top-left-radius: 0; border-bottom-left-radius: 0; } @@ -144,8 +144,8 @@ color: var(--rs-color-foreground-base-tertiary); } -.range_start, -.range_end { +.rangeStart, +.rangeEnd { color: var(--rs-color-foreground-base-emphasis); } @@ -183,15 +183,15 @@ gap: var(--rs-space-3); } -.dropdown_trigger { +.dropdownTrigger { padding: var(--rs-space-1) var(--rs-space-3); } -.dropdown_icon { +.dropdownIcon { margin-left: var(--rs-space-1); } -.dropdown_content { +.dropdownContent { max-height: 400px; } @@ -212,15 +212,19 @@ background-color: var(--rs-color-background-accent-emphasis); } -.selected.day.today:not(.range_middle) button::after { +.day.today .dayButtonWithInfo::after { + bottom: var(--rs-space-1); +} + +.selected.day.today:not(.rangeMiddle) button::after { background-color: var(--rs-color-foreground-base-emphasis); } -.range_middle.day.today button::after { +.rangeMiddle.day.today button::after { background-color: var(--rs-color-background-accent-emphasis); } -.day_button { +.dayButton { cursor: pointer; border: none; width: 100%; @@ -238,6 +242,47 @@ position: relative; } +.dayButtonWithInfo { + display: grid; + place-content: center; + position: relative; +} + +.dayInfo { + position: absolute; + top: -2px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0px; + width: 100%; + pointer-events: none; +} + +.selected:not(.rangeMiddle) .dayInfo { + color: var(--rs-color-foreground-base-emphasis); +} + +.selected:not(.rangeMiddle) .dayInfo * { + color: var(--rs-color-foreground-base-emphasis); +} + +.selected:not(.rangeMiddle) .dayInfo svg { + color: var(--rs-color-foreground-base-emphasis); + fill: var(--rs-color-foreground-base-emphasis); +} + +.dayNumber { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + position: relative; +} + .calendarFooter { padding: var(--rs-space-3); margin-top: var(--rs-space-2); diff --git a/packages/raystack/components/calendar/calendar.tsx b/packages/raystack/components/calendar/calendar.tsx index fcba549a..3fb94e44 100644 --- a/packages/raystack/components/calendar/calendar.tsx +++ b/packages/raystack/components/calendar/calendar.tsx @@ -7,7 +7,7 @@ import { ChevronUpIcon } from '@radix-ui/react-icons'; import { cva, cx } from 'class-variance-authority'; -import { ChangeEvent, useEffect, useState } from 'react'; +import { ChangeEvent, ReactNode, useEffect, useState } from 'react'; import { DayPicker, DayPickerProps, @@ -28,7 +28,8 @@ interface OnDropdownOpen { interface CalendarPropsExtended { showTooltip?: boolean; - tooltipMessages?: { [key: string]: any }; + tooltipMessages?: Record; + dateInfo?: Record | ((date: Date) => ReactNode | null); loadingData?: boolean; timeZone?: string; } @@ -70,9 +71,9 @@ function DropDown({ onOpenChange={setOpen} > - + @@ -114,6 +115,7 @@ export const Calendar = function ({ onDropdownOpen, showTooltip = false, tooltipMessages = {}, + dateInfo = {}, loadingData = false, timeZone, ...props @@ -154,15 +156,34 @@ export const Calendar = function ({ ), DayButton: props => { const { day, ...buttonProps } = props; - const message = - tooltipMessages[dateLib.format(day.date, 'dd-MM-yyyy')]; + const dateKey = dateLib.format(day.date, 'dd-MM-yyyy'); + const message = tooltipMessages[dateKey]; + + // Support both object and function for dateInfo + const dateComponent = + typeof dateInfo === 'function' + ? dateInfo(day.date) + : dateInfo[dateKey]; + const hasDateInfo = Boolean(dateComponent); + return ( - ); }, @@ -179,10 +200,10 @@ export const Calendar = function ({ ) }} classNames={{ - caption_label: styles.caption_label, - button_previous: `${styles.nav_button} ${styles.nav_button_previous}`, - button_next: `${styles.nav_button} ${styles.nav_button_next}`, - month_caption: styles.month_caption, + caption_label: styles.captionLabel, + button_previous: `${styles.navButton} ${styles.navButtonPrevious}`, + button_next: `${styles.navButton} ${styles.navButtonNext}`, + month_caption: styles.monthCaption, months: styles.months, nav: styles.nav, day: styles.day, @@ -193,10 +214,10 @@ export const Calendar = function ({ weekday: styles.weekday, disabled: styles.disabled, selected: styles.selected, - day_button: styles.day_button, - range_middle: styles.range_middle, - range_end: styles.range_end, - range_start: styles.range_start, + day_button: styles.dayButton, + range_middle: styles.rangeMiddle, + range_end: styles.rangeEnd, + range_start: styles.rangeStart, hidden: styles.hidden, dropdowns: styles.dropdowns, ...classNames diff --git a/packages/raystack/components/calendar/index.tsx b/packages/raystack/components/calendar/index.tsx index f237a5e1..697f683c 100644 --- a/packages/raystack/components/calendar/index.tsx +++ b/packages/raystack/components/calendar/index.tsx @@ -1,5 +1,3 @@ -export { Calendar } from "./calendar"; -export { DatePicker } from "./date-picker"; -export { RangePicker } from "./range-picker"; - -// Todo: Add weather forecast icon with text. \ No newline at end of file +export { Calendar } from './calendar'; +export { DatePicker } from './date-picker'; +export { RangePicker } from './range-picker'; diff --git a/packages/raystack/components/callout/callout.module.css b/packages/raystack/components/callout/callout.module.css index db206bd6..c447bef7 100644 --- a/packages/raystack/components/callout/callout.module.css +++ b/packages/raystack/components/callout/callout.module.css @@ -101,11 +101,7 @@ } .callout-gradient { - background: radial-gradient( - circle, - #AD00E933 0%, - #EF040433 100% - ); + background: radial-gradient(circle, #ad00e933 0%, #ef040433 100%); color: var(--rs-color-foreground-base-primary); } @@ -199,21 +195,13 @@ /* Gradient variants */ .callout-gradient { - background: radial-gradient( - circle, - #AD00E933 0%, - #EF040433 100% - ); + background: radial-gradient(circle, #ad00e933 0%, #ef040433 100%); color: var(--rs-color-foreground-base-primary); } .callout-outline.callout-gradient { - background: radial-gradient( - circle, - #AD00E933 0%, - #EF040433 100% - ); - border: 1px solid #EF040444; + background: radial-gradient(circle, #ad00e933 0%, #ef040433 100%); + border: 1px solid #ef040444; color: var(--rs-color-foreground-base-primary); } @@ -250,4 +238,4 @@ background: var(--rs-color-background-base-primary); border: 1px solid var(--rs-color-border-base-tertiary); color: var(--rs-color-foreground-base-primary); -} \ No newline at end of file +} diff --git a/packages/raystack/components/empty-state/empty-state.module.css b/packages/raystack/components/empty-state/empty-state.module.css index e554cd21..078e62ff 100644 --- a/packages/raystack/components/empty-state/empty-state.module.css +++ b/packages/raystack/components/empty-state/empty-state.module.css @@ -29,7 +29,6 @@ width: 100%; } - .icon::before { content: ""; position: absolute; @@ -110,4 +109,4 @@ width: 100%; height: 100%; padding: var(--rs-space-9) var(--rs-space-5); -} \ No newline at end of file +} diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index 488155e6..404fffe9 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -153,7 +153,7 @@ button.selectValue:hover { } /* Match height of InputField when FilterChip variant is text */ -.chip[data-variant='text'] .inputField { +.chip[data-variant="text"] .inputField { border: none; box-shadow: none; height: 24px; @@ -181,7 +181,7 @@ button.selectValue:hover { } /* Remove border and match height of the DatePicker when FilterChip variant is text */ -.chip[data-variant='text'] .dateField { +.chip[data-variant="text"] .dateField { border: none; box-shadow: none; height: 24px; diff --git a/packages/raystack/components/sheet/sheet.module.css b/packages/raystack/components/sheet/sheet.module.css index 2a7ba395..01d82681 100644 --- a/packages/raystack/components/sheet/sheet.module.css +++ b/packages/raystack/components/sheet/sheet.module.css @@ -185,4 +185,4 @@ .overlay { animation: none; } -} \ No newline at end of file +} diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index aee3411a..da0fc850 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -43,7 +43,7 @@ color: var(--rs-color-foreground-base-primary); } -.trigger[data-state='active'] { +.trigger[data-state="active"] { background-color: var(--rs-color-background-base-primary); color: var(--rs-color-foreground-base-primary); box-shadow: var(--rs-shadow-feather); @@ -72,6 +72,6 @@ outline: none; } -.content[data-state='inactive'] { +.content[data-state="inactive"] { display: none; -} \ No newline at end of file +} diff --git a/packages/raystack/components/text-area/text-area.module.css b/packages/raystack/components/text-area/text-area.module.css index 69e0af18..3dd4781f 100644 --- a/packages/raystack/components/text-area/text-area.module.css +++ b/packages/raystack/components/text-area/text-area.module.css @@ -117,4 +117,4 @@ .helperTextDisabled { color: var(--rs-color-foreground-base-tertiary); -} \ No newline at end of file +}