đź“„ ActivityCalendar.stories.tsx
/home/palash/git/site/node_modules/react-activity-calendar/src/components/ActivityCalendar.stories.tsx
Language: tsx • Lines: 768
import {
  cloneElement,
  useEffect,
  useMemo,
  useRef,
  type ForwardedRef,
  type ReactElement,
} from 'react'
import LinkTo from '@storybook/addon-links/react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useDarkMode } from '@vueless/storybook-dark-mode'
import Container from '../../.storybook/components/Container'
import exampleCustomization from '../../examples/customization?raw'
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 exampleTooltipsConfig from '../../examples/tooltips-config?raw'
import exampleTooltips from '../../examples/tooltips?raw'
import exampleTooltipsCSS from '../../src/styles/tooltips.css?raw'
import { Source } from '../docs/Source'
import { generateTestData } from '../lib/calendar'
import type { Theme } from '../types'
import { ActivityCalendar, type Props } from './ActivityCalendar'
import '../styles/tooltips.css'

type Story = StoryObj<Props>

const meta: Meta<ForwardedRef<Props>> = {
  title: 'React Activity Calendar',
  component: ActivityCalendar,
  argTypes: {
    data: {
      control: false,
    },
    blockMargin: {
      control: { type: 'range', min: 0, max: 20 },
    },
    blockRadius: {
      control: { type: 'range', min: 0, max: 20 },
    },
    blockSize: {
      control: { type: 'range', min: 4, max: 30 },
    },
    colorScheme: {
      control: false,
    },
    fontSize: {
      control: { type: 'range', min: 6, max: 32, step: 2 },
    },
    minLevel: {
      control: { type: 'range', min: -10, max: 5 },
    },
    maxLevel: {
      control: { type: 'range', min: -5, max: 10 },
    },
    ref: {
      control: false,
    },
    showWeekdayLabels: {
      control: 'boolean',
    },
    style: {
      control: false,
    },
    tooltips: {
      control: false,
    },
    weekStart: {
      options: [0, 1, 2, 3, 4, 5, 6],
      control: {
        type: 'select',
        labels: {
          0: 'Sunday (0)',
          1: 'Monday (1)',
          2: 'Tuesday (2)',
          3: 'Wednesday (3)',
          4: 'Thursday (4)',
          5: 'Friday (5)',
          6: 'Saturday (6)',
        },
      },
    },
  },
  decorators: [
    (Story, { args }) => {
      // @ts-expect-error unsure if typing forward refs correctly is possible
      args.colorScheme = useDarkMode() ? 'dark' : 'light'

      return <Story />
    },
  ],
  parameters: {
    controls: {
      sort: 'requiredFirst',
    },
    layout: 'centered',
  },
  tags: ['autodocs'],
}

// Storybook does not initialize the controls for some reason
const defaultProps = {
  blockMargin: 4,
  blockRadius: 2,
  blockSize: 12,
  fontSize: 14,
  loading: false,
  minLevel: 0,
  maxLevel: 4,
  showColorLegend: true,
  showMonthLabels: true,
  showTotalCount: true,
  showWeekdayLabels: false,
  weekStart: 0, // Sunday
} satisfies Omit<Props, 'data'>

export default meta
const explicitTheme: Theme = {
  light: ['#f0f0f0', '#c4edde', '#7ac7c4', '#f73859', '#384259'],
  dark: ['hsl(0, 0%, 22%)', '#4D455D', '#7DB9B6', '#F5E9CF', '#E96479'],
}

export const Default: Story = {
  args: defaultProps,
  render: args => {
    const data = useMemo(
      () =>
        generateTestData({
          minLevel: args.minLevel,
          maxLevel: args.maxLevel,
        }),
      [args.minLevel, args.maxLevel],
    )
    return <ActivityCalendar {...args} data={data} />
  },
  parameters: {
    docs: {
      source: {
        code: '<ActivityCalendar data={data} />',
      },
    },
  },
}

export const Loading: Story = {
  args: {
    ...defaultProps,
    data: [],
    loading: true,
  },
  parameters: {
    docs: {
      source: {
        code: '<ActivityCalendar data={data} loading />',
      },
    },
  },
}

export const ActivityLevels: Story = {
  args: {
    ...defaultProps,
    minLevel: 0,
    maxLevel: 2,
  },
  render: args => {
    const data = useMemo(
      () =>
        generateTestData({
          minLevel: args.minLevel,
          maxLevel: args.maxLevel,
        }),
      [args.minLevel, args.maxLevel],
    )

    return (
      <Container>
        <h1>Activity levels</h1>
        <ActivityCalendar
          {...args}
          data={data}
          labels={{
            legend: { less: String(args.minLevel), more: String(args.maxLevel) },
          }}
          style={{ margin: '1.5rem 0' }}
        />
        <p>
          Use the{' '}
          <code>
            <b>minLevel</b>
          </code>{' '}
          and{' '}
          <code>
            <b>maxLevel</b>
          </code>{' '}
          properties to control the range of activity levels. By default, the range is{' '}
          <code>[0, 4]</code>, resulting in 5 activity levels (0 through 4, inclusive). All activity
          data must be within these bounds.
        </p>
        <p>
          You can also specify a negative interval like <code>[-4, 4]</code>. To calculate a scale
          automatically pass exactly three colors representing the negative, zero and positive color
          (see{' '}
          <LinkTo kind="react-activity-calendar" name="color-themes">
            color themes
          </LinkTo>
          ).
        </p>
        <ActivityCalendar
          {...args}
          data={generateTestData({ minLevel: -4, maxLevel: 4 })}
          minLevel={-4}
          maxLevel={4}
          labels={{
            legend: { less: '-4', more: '4' },
          }}
          theme={{
            light: ['red', 'hsl(0, 0%, 92%)', 'green'],
          }}
          style={{ margin: '1.5rem 0' }}
        />
      </Container>
    )
  },
  parameters: {
    docs: {
      source: {
        code: '<ActivityCalendar data={data} levels={{min: 0, max: 2}} />',
      },
    },
  },
}

export const DateRanges: Story = {
  args: defaultProps,
  render: args => {
    const dataLong = useMemo(
      () =>
        generateTestData({
          minLevel: args.minLevel,
          maxLevel: args.maxLevel,
          interval: {
            start: new Date(2022, 5, 1),
            end: new Date(2023, 4, 31),
          },
        }),
      [args.minLevel, args.maxLevel],
    )

    const dataMedium = useMemo(
      () =>
        generateTestData({
          minLevel: args.minLevel,
          maxLevel: args.maxLevel,
          interval: {
            start: new Date(2023, 2, 8),
            end: new Date(2023, 7, 1),
          },
        }),
      [args.minLevel, args.maxLevel],
    )

    const dataShort = useMemo(
      () =>
        generateTestData({
          minLevel: args.minLevel,
          maxLevel: args.maxLevel,
          interval: {
            start: new Date(2023, 5, 14),
            end: new Date(2023, 6, 17),
          },
        }),
      [args.minLevel, args.maxLevel],
    )

    return (
      <Stack>
        <ActivityCalendar
          {...args}
          data={dataLong}
          labels={{
            totalCount: '{{count}} activities in 2022 & 2023',
          }}
        />
        <ActivityCalendar {...args} data={dataMedium} />
        <ActivityCalendar {...args} data={dataShort} />
      </Stack>
    )
  },
}

export const ColorThemes: Story = {
  args: {
    ...defaultProps,
    theme: {
      light: ['hsl(0, 0%, 92%)', 'rebeccapurple'],
      dark: ['hsl(0, 0%, 22%)', 'hsl(225,92%,77%)'],
    },
  },
  parameters: {
    docs: {
      source: {
        code: exampleTheme,
      },
    },
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )

    return (
      <Container>
        <h1>Color themes</h1>
        <ActivityCalendar {...args} data={data} style={{ margin: '2rem 0' }} />
        <p>
          Use the{' '}
          <code>
            <b>theme</b>
          </code>{' '}
          prop to configure the calendar colors for the light and dark{' '}
          <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme">color scheme</a>.
          Provide the colors for all activity levels per scheme{' '}
          <LinkTo kind="react-activity-calendar" name="explicit-themes">
            explicitly
          </LinkTo>{' '}
          or specify two colors (the zero and maximum intensity) to calculate a scale automatically.
          The number of activity levels is controlled by the minLevel and maxLevel properties, see{' '}
          <LinkTo kind="react-activity-calendar" name="activity-levels">
            activity levels
          </LinkTo>
          . If you have negative activity levels, you can also pass three colors, representing the
          negative, zero, and positive levels, to calculate a corresponding scale. For explicit
          themes the color count must match the number of activity levels. Colors can be specified
          in any valid CSS format.
        </p>
        <p>
          For undefined color schemes the default theme is used. By default, the current system
          color scheme is applied, but you can enforce a specific scheme with the{' '}
          <a href="/?path=/docs/react-activity-calendar--docs">
            <code>colorScheme</code>
          </a>{' '}
          prop.
        </p>
        <Source code={exampleTheme} isDarkMode={useDarkMode()} />
      </Container>
    )
  },
}

export const ExplicitThemes: Story = {
  args: {
    ...defaultProps,
    theme: explicitTheme,
  },
  parameters: {
    // Levels must not be set when using a static theme
    controls: { exclude: ['minLevel', 'maxLevel'] },
    docs: {
      source: {
        code: exampleThemeExplicit,
      },
    },
  },
  render: args => {
    const data = useMemo(() => generateTestData(), [])

    return (
      <Container>
        <h1>Explicit theme</h1>
        <p></p>
        <p>
          See the{' '}
          <LinkTo kind="react-activity-calendar" name="color-themes">
            color themes
          </LinkTo>{' '}
          page for details how to use the <code>theme</code> prop.
        </p>
        <ActivityCalendar {...args} data={data} style={{ marginTop: '2rem' }} />
      </Container>
    )
  },
}

export const Customization: Story = {
  args: {
    ...defaultProps,
    blockSize: 14,
    blockRadius: 7,
    blockMargin: 5,
    fontSize: 16,
    theme: explicitTheme,
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )
    return <ActivityCalendar {...args} data={data} />
  },
  parameters: {
    // Levels must not be set when using a static theme
    controls: { exclude: ['minLevel', 'maxLevel'] },
    docs: {
      source: {
        code: exampleCustomization,
      },
    },
  },
}

export const EventHandlers: Story = {
  args: {
    ...defaultProps,
    renderBlock: (block, activity) =>
      cloneElement(block, {
        onClick: () => {
          alert(JSON.stringify(activity))
        },
        onMouseEnter: () => {
          // eslint-disable-next-line no-console
          console.log('on mouse enter')
        },
      }),
  },
  parameters: {
    docs: {
      source: {
        code: exampleEventHandlers,
      },
    },
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )

    return (
      <Container>
        <h1>Event Handlers</h1>
        <p>
          Use the{' '}
          <code>
            <b>renderBlock</b>
          </code>{' '}
          prop to attach event handlers to the SVG <code>rect</code> elements that represent
          calendar days. Click on any block below to see it in action.
        </p>
        <ActivityCalendar {...args} data={data} style={{ margin: '2rem 0' }} />
        <p>
          Use the <code>React.cloneElement()</code> function to assign the handlers:
        </p>
        <Source code={exampleEventHandlers} isDarkMode={useDarkMode()} />
      </Container>
    )
  },
}

export const Tooltips: Story = {
  args: {
    ...defaultProps,
    tooltips: {
      activity: {
        text: ({ count, date }) => `${count} activities on ${date}`,
      },
      colorLegend: {
        text: level => `Activity level ${level}`,
      },
    },
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )

    return (
      <Container>
        <h1>Tooltips</h1>
        <p>
          Use the{' '}
          <code>
            <b>tooltips</b>
          </code>{' '}
          prop to show tooltips when hovering the calendar days or the color legend. Each tooltip's
          content is generated by a callback function, which receives either the activity data or
          level value of the hovered element.
        </p>
        <ActivityCalendar {...args} data={data} style={{ margin: '2rem 0' }} />
        <p>
          Tooltips no longer depend on external libraries and are now integrated directly into this
          package. Thanks to code-splitting, tooltips only affect your bundle size when you use
          them. Tooltips are implemented using the{' '}
          <a href="https://floating-ui.com/">Floating UI</a> library as a “headless” component,
          meaning they come <b>without predefined styles</b>. This gives you full control over the
          appearance:
        </p>
        <ul>
          <li>
            Import the default styles provided by this package, <b>or</b>
          </li>
          <li>Add your own custom CSS.</li>
        </ul>
        <Source code="import 'react-activity-calendar/tooltips.css';" isDarkMode={useDarkMode()} />
        <Source
          code={`/* Adapt to your needs */\n${exampleTooltipsCSS}`}
          isDarkMode={useDarkMode()}
        />

        <h2>Tooltip configuration</h2>
        <p>You can configure the tooltips with the following optional settings:</p>
        <ul>
          <li>
            <a href="https://floating-ui.com/docs/useFloating#placement">placement</a> of the
            tooltip
          </li>
          <li>
            <a href="https://floating-ui.com/docs/offset#options">offset</a> to the element in
            pixels (4 by default)
          </li>
          <li>
            the cursor “<a href="https://floating-ui.com/docs/useHover#restms">rest time</a>” in
            milliseconds before opening a tooltip (150ms by default)
          </li>
          <li>
            <a href="https://floating-ui.com/docs/useTransition#usetransitionstyles-props">
              transition styles
            </a>{' '}
            to fine-tune CSS animations
          </li>
          <li>whether to draw an arrow (false by default)</li>
        </ul>
        <Source code={exampleTooltipsConfig} isDarkMode={useDarkMode()} />
        <ActivityCalendar
          {...args}
          data={data}
          tooltips={{
            activity: {
              text: activity => `${activity.level} activities on ${activity.date}`,
              placement: 'right',
              offset: 8,
              transitionStyles: {
                duration: 100,
                common: { fontFamily: 'monospace' },
              },
              hoverRestMs: 300,
              withArrow: true,
            },
          }}
          style={{ margin: '2rem 0' }}
        />
      </Container>
    )
  },
  parameters: {
    docs: {
      source: {
        code: exampleTooltips,
      },
    },
  },
}

export const WithoutLabels: Story = {
  args: {
    ...defaultProps,
    showMonthLabels: false,
    showColorLegend: false,
    showTotalCount: false,
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )
    return <ActivityCalendar {...args} data={data} />
  },
  parameters: {
    docs: {
      source: {
        code: '<ActivityCalendar data={data} showMonthLabels={false} showColorLegend={false} showTotalCount={false} />',
      },
    },
  },
}

export const WeekdayLabels: Story = {
  args: {
    ...defaultProps,
    showWeekdayLabels: true,
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )
    return (
      <Stack>
        <div>
          <StackHeading code="true">Show every second weekday (default)</StackHeading>
          <ActivityCalendar {...args} data={data} />
        </div>

        <div>
          <StackHeading code="['mon', 'fri']">Show specific days</StackHeading>
          <ActivityCalendar {...args} data={data} showWeekdayLabels={['mon', 'fri']} />
        </div>

        <div>
          <StackHeading code="['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']">
            Show every day
          </StackHeading>
          <ActivityCalendar
            {...args}
            data={data}
            showWeekdayLabels={['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']}
          />
        </div>
      </Stack>
    )
  },
  parameters: {
    docs: {
      source: {
        code: '<ActivityCalendar data={data} showWeekdayLabels />',
      },
    },
  },
}

export const LocalizedLabels: Story = {
  args: {
    ...defaultProps,
    showWeekdayLabels: true,
    labels: {
      months: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
      weekdays: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
      totalCount: '{{count}} Aktivitäten in {{year}}',
      legend: {
        less: 'Weniger',
        more: 'Mehr',
      },
    },
  },
  parameters: {
    docs: {
      source: {
        code: exampleLabels,
      },
    },
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )

    return (
      <Container>
        <h1>Localization</h1>
        <p>Example in German:</p>
        <ActivityCalendar {...args} data={data} style={{ margin: '2rem 0' }} />
        <Source code={exampleLabelsShape} isDarkMode={useDarkMode()} />
      </Container>
    )
  },
}

export const MondayAsWeekStart: Story = {
  args: {
    ...defaultProps,
    weekStart: 1,
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )
    return <ActivityCalendar {...args} data={data} />
  },
  parameters: {
    docs: {
      source: {
        code: '<ActivityCalendar data={data} weekStart={1} />',
      },
    },
  },
}

export const NarrowScreens: Story = {
  args: defaultProps,
  parameters: {
    docs: {
      source: {
        code: '<ActivityCalendar data={data} />',
      },
    },
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )

    return (
      <div style={{ width: 480, maxWidth: '100%', border: 'dashed 1px #929292' }}>
        <ActivityCalendar {...args} data={data} />
      </div>
    )
  },
}

export const ContainerRef: Story = {
  args: defaultProps,
  parameters: {
    docs: {
      source: {
        code: exampleRef,
      },
    },
  },
  render: args => {
    const data = useMemo(
      () => generateTestData({ minLevel: args.minLevel, maxLevel: args.maxLevel }),
      [args.minLevel, args.maxLevel],
    )
    const calendarRef = useRef<HTMLElement>(null)

    useEffect(() => {
      // eslint-disable-next-line no-console
      console.log('calendar ref', calendarRef)
    }, [calendarRef])

    return (
      <>
        <ActivityCalendar {...args} data={data} ref={calendarRef} />
        <br />
        <p>Check the JavaScript console to see the ref logged.</p>
      </>
    )
  },
}

const Stack = ({ children }: { children: Array<ReactElement> }) => (
  <div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>{children}</div>
)

const StackHeading = ({ children, code }: { children: string; code?: string }) => (
  <div
    role="heading"
    style={{
      display: 'flex',
      alignItems: 'center',
      gap: 12,
      marginBottom: 16,
      fontSize: 16,
      fontWeight: 'bolder',
    }}
  >
    {children}
    {code && <code style={{ fontSize: 13, fontWeight: 'normal' }}>{code}</code>}
  </div>
)