import { endOfDay, isSameDay, startOfDay } from "date-fns"; import React, { forwardRef, useMemo, useRef, useState } from "react"; import { AxisLabels } from "./AxisLabels"; import TimelineRow, { TimelineRowType } from "./TimelineRow"; import { RowContext } from "./hooks/useRowContext"; import { TimelineContext } from "./hooks/useTimelineContext"; import { useEarliestDate, useLatestDate, useTimelineRows, } from "./hooks/useTimelineRows"; import Period, { PeriodType } from "./period"; import Pin, { PinType } from "./pin/Pin"; import { parseRows } from "./utils/timeline"; import { AxisLabelTemplates } from "./utils/types.external"; import Zoom, { ZoomType } from "./zoom"; export interface TimelineProps extends React.HTMLAttributes { children: React.ReactNode; /** * Decides startingpoint in timeline. * Defaults to earliest date among the timeline periods. * * Using this disables use of ZoomButtons. You will need to control zooming yourself. */ startDate?: Date; /** * Decides end-date for timeline. * Defaults to the latest date among the timeline periods. * * Using this disables use of ZoomButtons. You will need to control zooming yourself. */ endDate?: Date; /** * Decides direction which periods are sorted/displayed. "left" ascends from earliest date on left. * @default "left" */ direction?: "left" | "right"; /** * Templates for label texts. The templates are passed to the date-fns `format` function. * Defaults to { day: "dd.MM", month: "MMM yy", year: "yyyy" }. * @deprecated Use ``-component */ axisLabelTemplates?: AxisLabelTemplates; } interface TimelineComponent extends React.ForwardRefExoticComponent { /** * @see 🏷️ {@link TimelineRowType} */ Row: TimelineRowType; /** * @see 🏷️ {@link PeriodType} */ Period: PeriodType; /** * @see 🏷️ {@link PinType} */ Pin: PinType; /** * @see 🏷️ {@link ZoomType} */ Zoom: ZoomType; } /** * A component that displays a timeline of events. Meant for Internal systems. * * Component is made for desktop enviroments and will start having issues on smaller screens. * * @see [📝 Documentation](https://aksel.nav.no/komponenter/core/timeline) * @see 🏷️ {@link TimelineProps} * * @example * ```jsx * * * *

Period 1

*
* *
* ``` */ export const Timeline = forwardRef( ( { children, startDate, endDate, direction = "left", axisLabelTemplates, ...rest }, ref, ) => { const isMultipleRows = Array.isArray(children); const firstFocusabled = useRef< { ref: HTMLButtonElement | null; id: number }[] >([]); if (!isMultipleRows) { children = [children]; } const rowChildren = React.Children.toArray(children).filter( (c: any) => c?.type?.componentType === "row", ); const pins = React.Children.toArray(children) .filter((c: any) => c?.type?.componentType === "pin") .map((x) => () => x); const zoomComponent = React.Children.toArray(children).find( (c: any) => c?.type?.componentType === "zoom", ); const rowsRaw = useMemo(() => { return parseRows(rowChildren); }, [rowChildren]); const rows = rowsRaw.map((r) => { if (r?.periods) { return r.periods; } return []; }); const initialStartDate = startOfDay(useEarliestDate({ startDate, rows })); const [start, setStart] = useState(initialStartDate); const [activeRow, setActiveRow] = useState(null); const [endInclusive, setEndInclusive] = useState( endOfDay(useLatestDate({ endDate, rows })), ); const initialEndDate = endOfDay(useLatestDate({ endDate, rows })); const processedRows = useTimelineRows( rowsRaw, startDate ?? start, endDate ?? endInclusive, direction, ); const handleZoomChange = (zoomStart: Date) => { if (startDate || endDate) { if (process.env.NODE_ENV !== "production") { console.warn( "Zooming is not supported when startDate or endDate is set", ); } return; } if (direction === "left") { if (isSameDay(zoomStart, start)) { setStart(initialStartDate); return; } setStart(zoomStart); } else { if (isSameDay(zoomStart, endInclusive)) { setEndInclusive(initialEndDate); return; } setEndInclusive(zoomStart); } }; const handleActiveRowChange = (key: string) => { if (activeRow !== null && key === "ArrowDown") { for (let i = activeRow + 1; i < processedRows.length; i++) { const row = processedRows[i]; if (row.periods.find((p) => !!p.children || !!p.onSelectPeriod)) { setActiveRow(i); firstFocusabled.current.find((x) => x.id === i)?.ref?.focus(); break; } } return; } if (activeRow !== null && key === "ArrowUp") { for (let i = activeRow - 1; i >= 0; i--) { const row = processedRows[i]; if (row.periods.find((p) => !!p.children || !!p.onSelectPeriod)) { setActiveRow(i); firstFocusabled.current.find((x) => x.id === i)?.ref?.focus(); break; } } return; } }; const addFocusable = (btnRef: HTMLButtonElement | null, id: number) => { let items = firstFocusabled.current; items = items.filter((x) => x.id !== id); items.push({ ref: btnRef, id }); firstFocusabled.current = items; }; return ( handleZoomChange(d), setEndInclusive: (d) => setEndInclusive(d), activeRow, setActiveRow: (key) => handleActiveRowChange(key), initiate: (i) => setActiveRow(i), addFocusable, }} >
{pins.map((PinChild, i) => ( ))} {processedRows.map((row, i) => { return ( ); })}
{zoomComponent}
); }, ) as TimelineComponent; Timeline.Row = TimelineRow; Timeline.Period = Period; Timeline.Pin = Pin; Timeline.Zoom = Zoom; export default Timeline;