diff --git a/.storybook/main.ts b/.storybook/main.ts index 415b6ba..ea59db6 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -16,9 +16,6 @@ const config: StorybookConfig = { '@storybook/addon-links', 'storybook-dark-mode', ], - docs: { - autodocs: true, - }, typescript: { reactDocgen: 'react-docgen', }, diff --git a/examples/ref.tsx b/examples/ref.tsx new file mode 100644 index 0000000..2a00211 --- /dev/null +++ b/examples/ref.tsx @@ -0,0 +1,10 @@ +// @ts-nocheck +import { useRef } from "react"; + +const calendarRef = useRef(null); + +if (calendar.current) { + console.log(calendarRef.current); +} + + diff --git a/package.json b/package.json index ae645ad..76be3fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-activity-calendar", - "version": "2.2.11", + "version": "2.3.0", "description": "React component to display activity data in calendar", "author": "Jonathan Gruber ", "license": "MIT", diff --git a/src/component/ActivityCalendar.stories.tsx b/src/component/ActivityCalendar.stories.tsx index 4104445..0d1c8d4 100644 --- a/src/component/ActivityCalendar.stories.tsx +++ b/src/component/ActivityCalendar.stories.tsx @@ -2,7 +2,7 @@ import { Tooltip as MuiTooltip } from '@mui/material'; import LinkTo from '@storybook/addon-links/react'; import type { Meta, StoryObj } from '@storybook/react'; import { Highlight, themes as prismThemes } from 'prism-react-renderer'; -import { cloneElement, useMemo } from 'react'; +import { type ForwardedRef, cloneElement, useMemo, useRef } from 'react'; import { Tooltip as ReactTooltip } from 'react-tooltip'; import 'react-tooltip/dist/react-tooltip.css'; import { useDarkMode } from 'storybook-dark-mode'; @@ -13,6 +13,7 @@ import exampleEventHandlersInterface from '../../examples/event-handlers-type?ra import exampleEventHandlers from '../../examples/event-handlers?raw'; import exampleLabelsShape from '../../examples/labels-shape?raw'; import exampleLabels from '../../examples/labels?raw'; +import exampleRef from '../../examples/ref?raw'; import exampleThemeExplicit from '../../examples/themes-explicit?raw'; import exampleTheme from '../../examples/themes?raw'; import exampleTooltipsMui from '../../examples/tooltips-mui?raw'; @@ -25,22 +26,9 @@ type Story = StoryObj; /* eslint-disable react-hooks/rules-of-hooks */ -const meta: Meta = { +const meta: Meta> = { title: 'React Activity Calendar', component: ActivityCalendar, - decorators: [ - (Story, { args }) => { - args.colorScheme = useDarkMode() ? 'dark' : 'light'; - - return ; - }, - ], - parameters: { - controls: { - sort: 'requiredFirst', - }, - layout: 'centered', - }, argTypes: { data: { control: false, @@ -63,6 +51,9 @@ const meta: Meta = { maxLevel: { control: { type: 'range', min: 1, max: 9 }, }, + ref: { + control: false, + }, style: { control: false, }, @@ -82,6 +73,21 @@ const meta: Meta = { }, }, }, + decorators: [ + (Story, { args }) => { + // @ts-expect-error unsure if typing forward refs correctly is possible + args.colorScheme = useDarkMode() ? 'dark' : 'light'; + + return ; + }, + ], + parameters: { + controls: { + sort: 'requiredFirst', + }, + layout: 'centered', + }, + tags: ['autodocs'], }; // Storybook does not initialize the controls for some reason @@ -559,6 +565,30 @@ export const NarrowScreens: Story = { }, }; +export const ContainerRef: Story = { + args: defaultProps, + parameters: { + docs: { + source: { + code: exampleRef, + }, + }, + }, + render: args => { + const data = useMemo(() => generateTestData({ maxLevel: args.maxLevel }), [args.maxLevel]); + const calendarRef = useRef(null); + console.log('calendar ref', calendarRef); + + return ( + <> + +
+

Check the JavaScript console to see the ref logged.

+ + ); + }, +}; + const Source = ({ code, isDarkMode, diff --git a/src/component/ActivityCalendar.tsx b/src/component/ActivityCalendar.tsx index 223f44b..1161811 100644 --- a/src/component/ActivityCalendar.tsx +++ b/src/component/ActivityCalendar.tsx @@ -3,7 +3,13 @@ import chroma from 'chroma-js'; import type { Day as WeekDay } from 'date-fns'; import { getYear, parseISO } from 'date-fns'; -import { type CSSProperties, Fragment, type ReactElement } from 'react'; +import { + type CSSProperties, + type ForwardedRef, + Fragment, + type ReactElement, + forwardRef, +} from 'react'; import { DEFAULT_LABELS, LABEL_MARGIN, NAMESPACE } from '../constants'; import { useColorScheme } from '../hooks/useColorScheme'; @@ -104,6 +110,10 @@ export interface Props { * Toggle for loading state. `data` property will be ignored if set. */ loading?: boolean; + /** + * Ref to access the calendar DOM node. + */ + ref?: ForwardedRef; /** * Render prop for calendar blocks (activities). For example, useful to wrap * the element with a tooltip component. Use `React.cloneElement` to pass @@ -152,266 +162,272 @@ export interface Props { weekStart?: WeekDay; } -const ActivityCalendar = ({ - data: activities, - blockMargin = 4, - blockRadius = 2, - blockSize = 12, - colorScheme = undefined, - eventHandlers = {}, - fontSize = 14, - hideColorLegend = false, - hideMonthLabels = false, - hideTotalCount = false, - labels: labelsProp = undefined, - maxLevel = 4, - loading = false, - renderBlock = undefined, - showWeekdayLabels = false, - style: styleProp = {}, - theme: themeProp = undefined, - totalCount: totalCountProp = undefined, - weekStart = 0, // Sunday -}: Props) => { - maxLevel = Math.max(1, maxLevel); - - const theme = createTheme(themeProp, maxLevel + 1); - const systemColorScheme = useColorScheme(); - const colorScale = theme[colorScheme ?? systemColorScheme]; - - const useAnimation = !usePrefersReducedMotion(); - - // Calculating the weekday label offset only works in the browser. - // So disable SSR in this case. - const isClient = useIsClient(); - if (showWeekdayLabels && !isClient) { - return null; - } - - if (loading) { - activities = generateEmptyData(); - } - - if (activities.length === 0) { - return null; - } - - const firstActivity = activities[0] as Activity; - const year = getYear(parseISO(firstActivity.date)); - const weeks = groupByWeeks(activities, weekStart); - const firstWeek = weeks[0] as Week; - - const labels = Object.assign({}, DEFAULT_LABELS, labelsProp); - const labelHeight = hideMonthLabels ? 0 : fontSize + LABEL_MARGIN; - - const weekdayLabelOffset = showWeekdayLabels - ? maxWeekdayLabelLength(firstWeek, weekStart, labels.weekdays, fontSize) + LABEL_MARGIN - : undefined; - - function getDimensions() { - return { - width: weeks.length * (blockSize + blockMargin) - blockMargin, - height: labelHeight + (blockSize + blockMargin) * 7 - blockMargin, - }; - } +const ActivityCalendar = forwardRef( + ( + { + data: activities, + blockMargin = 4, + blockRadius = 2, + blockSize = 12, + colorScheme = undefined, + eventHandlers = {}, + fontSize = 14, + hideColorLegend = false, + hideMonthLabels = false, + hideTotalCount = false, + labels: labelsProp = undefined, + maxLevel = 4, + loading = false, + renderBlock = undefined, + showWeekdayLabels = false, + style: styleProp = {}, + theme: themeProp = undefined, + totalCount: totalCountProp = undefined, + weekStart = 0, // Sunday + }: Props, // Required for react-docgen + ref, + ) => { + maxLevel = Math.max(1, maxLevel); + + const theme = createTheme(themeProp, maxLevel + 1); + const systemColorScheme = useColorScheme(); + const colorScale = theme[colorScheme ?? systemColorScheme]; + + const useAnimation = !usePrefersReducedMotion(); + + // Calculating the weekday label offset only works in the browser. + // So disable SSR in this case. + const isClient = useIsClient(); + if (showWeekdayLabels && !isClient) { + return null; + } - function getEventHandlers(activity: Activity): SVGRectEventHandler { - return ( - Object.keys(eventHandlers) as Array - ).reduce( - (handlers, key) => ({ - ...handlers, - [key]: (event: ReactEvent) => eventHandlers[key]?.(event)(activity), - }), - {}, - ); - } - - function renderCalendar() { - return weeks - .map((week, weekIndex) => - week.map((activity, dayIndex) => { - if (!activity) { - return null; - } - - if (activity.level < 0 || activity.level > maxLevel) { - throw new RangeError( - `Provided activity level ${activity.level} for ${activity.date} is out of range. It must be between 0 and ${maxLevel}.`, - ); - } + if (loading) { + activities = generateEmptyData(); + } - const style = - loading && useAnimation - ? { - animation: `${styles.loadingAnimation} 1.75s ease-in-out infinite`, - animationDelay: `${weekIndex * 20 + dayIndex * 20}ms`, - } - : undefined; - - const block = ( - - ); - - return ( - - {renderBlock ? renderBlock(block, activity) : block} - - ); - }), - ) - .map((week, x) => ( - - {week} - - )); - } - - function renderFooter() { - if (hideTotalCount && hideColorLegend) { + if (activities.length === 0) { return null; } - const totalCount = - typeof totalCountProp === 'number' - ? totalCountProp - : activities.reduce((sum, activity) => sum + activity.count, 0); + const firstActivity = activities[0] as Activity; + const year = getYear(parseISO(firstActivity.date)); + const weeks = groupByWeeks(activities, weekStart); + const firstWeek = weeks[0] as Week; - return ( -
- {/* Placeholder */} - {loading &&
 
} - - {!loading && !hideTotalCount && ( -
- {labels.totalCount - ? labels.totalCount - .replace('{{count}}', String(totalCount)) - .replace('{{year}}', String(year)) - : `${totalCount} activities in ${year}`} -
- )} - - {!loading && !hideColorLegend && ( -
- {labels.legend.less} - {Array(maxLevel + 1) - .fill(undefined) - .map((_, level) => ( - - - - ))} - {labels.legend.more} -
- )} -
- ); - } + const labels = Object.assign({}, DEFAULT_LABELS, labelsProp); + const labelHeight = hideMonthLabels ? 0 : fontSize + LABEL_MARGIN; - function renderLabels() { - if (!showWeekdayLabels && hideMonthLabels) { - return null; + const weekdayLabelOffset = showWeekdayLabels + ? maxWeekdayLabelLength(firstWeek, weekStart, labels.weekdays, fontSize) + LABEL_MARGIN + : undefined; + + function getDimensions() { + return { + width: weeks.length * (blockSize + blockMargin) - blockMargin, + height: labelHeight + (blockSize + blockMargin) * 7 - blockMargin, + }; } - return ( - <> - {showWeekdayLabels && weeks[0] && ( - - {weeks[0].map((_, index) => { - if (index % 2 === 0) { - return null; - } + function getEventHandlers(activity: Activity): SVGRectEventHandler { + return ( + Object.keys(eventHandlers) as Array + ).reduce( + (handlers, key) => ({ + ...handlers, + [key]: (event: ReactEvent) => eventHandlers[key]?.(event)(activity), + }), + {}, + ); + } - const dayIndex = (index + weekStart) % 7; + function renderCalendar() { + return weeks + .map((week, weekIndex) => + week.map((activity, dayIndex) => { + if (!activity) { + return null; + } + + if (activity.level < 0 || activity.level > maxLevel) { + throw new RangeError( + `Provided activity level ${activity.level} for ${activity.date} is out of range. It must be between 0 and ${maxLevel}.`, + ); + } + + const style = + loading && useAnimation + ? { + animation: `${styles.loadingAnimation} 1.75s ease-in-out infinite`, + animationDelay: `${weekIndex * 20 + dayIndex * 20}ms`, + } + : undefined; + + const block = ( + + ); - return ( + return ( + + {renderBlock ? renderBlock(block, activity) : block} + + ); + }), + ) + .map((week, x) => ( + + {week} + + )); + } + + function renderFooter() { + if (hideTotalCount && hideColorLegend) { + return null; + } + + const totalCount = + typeof totalCountProp === 'number' + ? totalCountProp + : activities.reduce((sum, activity) => sum + activity.count, 0); + + return ( +
+ {/* Placeholder */} + {loading &&
 
} + + {!loading && !hideTotalCount && ( +
+ {labels.totalCount + ? labels.totalCount + .replace('{{count}}', String(totalCount)) + .replace('{{year}}', String(year)) + : `${totalCount} activities in ${year}`} +
+ )} + + {!loading && !hideColorLegend && ( +
+ {labels.legend.less} + {Array(maxLevel + 1) + .fill(undefined) + .map((_, level) => ( + + + + ))} + {labels.legend.more} +
+ )} +
+ ); + } + + function renderLabels() { + if (!showWeekdayLabels && hideMonthLabels) { + return null; + } + + return ( + <> + {showWeekdayLabels && weeks[0] && ( + + {weeks[0].map((_, index) => { + if (index % 2 === 0) { + return null; + } + + const dayIndex = (index + weekStart) % 7; + + return ( + + {labels.weekdays[dayIndex]} + + ); + })} + + )} + {!hideMonthLabels && ( + + {getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => ( - {labels.weekdays[dayIndex]} + {label} - ); - })} - - )} - {!hideMonthLabels && ( - - {getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => ( - - {label} - - ))} - - )} - + ))} +
+ )} + + ); + } + + const { width, height } = getDimensions(); + + const zeroColor = colorScale[0] as Color; + const containerStyles = { + fontSize, + ...(useAnimation && { + [`--${NAMESPACE}-loading`]: zeroColor, + [`--${NAMESPACE}-loading-active`]: + colorScheme === 'light' + ? chroma(zeroColor).darken(0.3).hex() + : chroma(zeroColor).brighten(0.25).hex(), + }), + }; + + return ( +
+
+ + {!loading && renderLabels()} + {renderCalendar()} + +
+ {renderFooter()} +
); - } - - const { width, height } = getDimensions(); - - const zeroColor = colorScale[0] as Color; - const containerStyles = { - fontSize, - ...(useAnimation && { - [`--${NAMESPACE}-loading`]: zeroColor, - [`--${NAMESPACE}-loading-active`]: - colorScheme === 'light' - ? chroma(zeroColor).darken(0.3).hex() - : chroma(zeroColor).brighten(0.25).hex(), - }), - }; - - return ( -
-
- - {!loading && renderLabels()} - {renderCalendar()} - -
- {renderFooter()} -
- ); -}; + }, +); export const Skeleton = (props: Omit) => ;