diff --git a/README.md b/README.md index 9003108..8eb7721 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # react-alternating-timeline -A compact, masonry style alternating timeline react component which is fully customizable. +A simple and compact, true masonry style alternating timeline react component which is fully customizable and free stylable. ![Demonstration](./docs/demonstration.jpg) @@ -57,7 +57,7 @@ The available properties of the `Timeline` component: | `formatDate?` | `(date: Date) => string` | Callback to format date | | | `customMarker?` | `ReactElement` | Custom maker element replacing the default | | | `customPointer?` | `ReactElement` | Custom pointer element replacing the default | | -| `styleConfig?` | [`StyleConfig`](#styleconfig) | Style config object for customizing timeline by setting css custom properties | | +| `styleConfig?` | [`StyleConfig`](#styling) | Style config object for customizing timeline by setting css custom properties | | | `className?` | `string` | Additional class name | | ### TimelineItemsProps @@ -74,65 +74,94 @@ An array of the following properties: | `customMarker?` | `ReactElement` | Overwriting `customMarker` property of parent `Timeline` | | `customPointer?` | `ReactElement` | Overwriting `customPointer` property of parent `Timeline` | -### StyleConfig +## Styling -The style can either be passed as a javascript object... +The style can either be passed as an object through the `styleConfig` property... ```ts { line?: { - width?: string; - color?: string; + width?: CSSProperties['width']; + color?: CSSProperties['backgroundColor']; + overhang?: CSSProperties['paddingBlock']; }; - gap?: string; - offset?: { - left?: string; - right?: string; + item?: { + gap?: CSSProperties['gap']; + startOffset?: { + left?: CSSProperties['marginTop']; + right?: CSSProperties['marginTop']; + }; }; marker?: { - size?: string; - color?: string; - radius?: string; + size?: CSSProperties['width']; + color?: CSSProperties['backgroundColor']; + radius?: CSSProperties['borderRadius']; }; pointer?: { - height?: string; - width?: string; - offset?: string; + height?: CSSProperties['height']; + width?: CSSProperties['width']; + minOffset?: CSSProperties['marginTop']; }; card?: { - background?: string; - radius?: string; - offset?: string; - shadow?: string; - padding?: string; + background?: CSSProperties['backgroundColor']; + radius?: CSSProperties['borderRadius']; + shadow?: CSSProperties['boxShadow']; + padding?: CSSProperties['padding']; + offset?: CSSProperties['gap']; }; -}; +} ``` -...or the custom properties can be set directly in css +...or can be set as custom properties directly in css ```css .timeline { --line-width: 0.2rem; --line-color: black; + --line-overhang: 1rem; + --item-gap: 1rem; + --item-start-offset-left: 0; + --item-start-offset-right: 5rem; --marker-size: 1rem; --marker-color: var(--line-color); --marker-radius: 50%; --pointer-height: 2rem; --pointer-width: 1rem; - --pointer-offset: 5rem; + --pointer-min-offset: 5rem; --card-background: whitesmoke; --card-radius: 0.1rem; - --card-offset: 1rem; --card-shadow: unset; --card-padding: 1rem; - --gap: 1rem; - --offset-left: 0; - --offset-right: 5rem; + --card-offset: 1rem; } ``` -(These are the default values) +### StyleConfig + +| Name | Description | Default | +| :------------------------- | :------------------------------------------------------------------------------------------------------------- | :------------ | +| **Line** | The line the timeline items are place around/beside | | +| – `line-width` | Width of the line | `0.2rem` | +| – `line-color` | Color of the line | `black` | +| – `line-overhang` | How much the line should overhang the beginning and end of the timeline component | `1rem` | +| **Item** | The timeline item as a whole, including the card, pointer and marker | | +| – `item-gap` | The vertical space between the items | `1rem` | +| – `item-start-offset-left` | How much the items on the left side should be offset from the top | `0` | +| – `item-start-offset-left` | How much the items on the right side should be offset from the top | `5rem` | +| **Marker** | The markers on the line which marks the chronological order of the timeline items | `1rem` | +| – `marker-size` | Size of the default marker | `1rem` | +| – `marker-color` | Color of the default marker | `line-color` | +| – `marker-radius` | Border radius (roundness) of the marker edges | `50%` (round) | +| **Pointer** | The pointers pointing from the item cards to the markers on the line | | +| – `pointer-height` | Height of the default pointer | `2rem` | +| – `pointer-width` | Width of the default pointer | `1rem` | +| – `pointer-min-offset` | Minimum offset of the pointer to the top of the card. The actual offset depends on the `minMarkerGap` property | `5rem` | +| **Card** | The cards in which the timeline item content is displayed | | +| – `card-background` | Background color of the card | `whitesmoke` | +| – `card-radius` | Border radius of the card edges | `0.1rem` | +| – `card-shadow` | Configure drop shadow of the card | `unset` | +| – `card-padding` | Padding of the card content | `1rem` | +| – `card-offset` | Space between the card and the timeline line | `1rem` | ## Demo diff --git a/src/components/Timeline.css b/src/components/Timeline.css index c868861..4a9cc9e 100644 --- a/src/components/Timeline.css +++ b/src/components/Timeline.css @@ -1,45 +1,61 @@ :where(.timeline) { --line-width: 0.2rem; --line-color: black; + --line-overhang: 1rem; + --item-gap: 1rem; + --item-start-offset-left: 0; + --item-start-offset-right: 5rem; --marker-size: 1rem; --marker-color: var(--line-color); --marker-radius: 50%; --pointer-height: 2rem; --pointer-width: 1rem; - --pointer-offset: 5rem; + --pointer-min-offset: 5rem; --card-background: whitesmoke; --card-radius: 0.1rem; - --card-offset: 1rem; --card-shadow: unset; --card-padding: 1rem; - --gap: 1rem; - --offset-left: 0; - --offset-right: 5rem; - - position: relative; + --card-offset: 1rem; } .timeline { display: flex; } -.timeline__line { +.timeline-line { background-color: var(--line-color); min-width: var(--line-width); + position: relative; +} + +.timeline-line__end { + position: absolute; + left: 50%; + transform: translate(-50%); +} + +.timeline-line__end--opening { + top: 0; +} + +.timeline-line__end--closing { + bottom: 0; } .timeline__items-container { display: flex; + padding-block: var(--line-overhang); flex-direction: column; flex: 1; + gap: var(--item-gap); } .timeline__items-container--left { - margin-top: var(--offset-left); + margin-top: var(--item-start-offset-left); } .timeline__items-container--right { - margin-top: var(--offset-right); + margin-top: var(--item-start-offset-right); } .timeline-item { @@ -59,7 +75,7 @@ } .timeline-item__marker { - margin-top: var(--pointer-offset); + margin-top: var(--pointer-min-offset); } .timeline-item__marker:not(.timeline-item__marker--custom) { diff --git a/src/components/Timeline.stories.tsx b/src/components/Timeline.stories.tsx index b6a269e..141c2e7 100644 --- a/src/components/Timeline.stories.tsx +++ b/src/components/Timeline.stories.tsx @@ -161,13 +161,18 @@ export const SpacingOptions: StoryObj = { items, ...defaultTimelineConfig, styleConfig: { - gap: '5rem', - offset: { - left: '10rem', - right: '3rem', + item: { + gap: '5rem', + startOffset: { + left: '10rem', + right: '3rem', + }, }, pointer: { - offset: '4rem', + minOffset: '4rem', + }, + line: { + overhang: '4rem', }, }, minMarkerGap: 150, @@ -257,8 +262,12 @@ customItems[3] = { customPointer:
, }; -export const CustomMarkerAndPointer: StoryObj = { +export const CustomMarkerPointerAndEnds: StoryObj = { args: { + customTimelineEnds: { + opening:
, + closing:
, + }, items: customItems, ...defaultTimelineConfig, customMarker: 🔥, diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index dbcaf9c..b79ba25 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -3,6 +3,7 @@ import { Key, ReactElement, useEffect, useRef } from 'react'; import { TimelineItem, TimelineItemRefs, TimelineItemsProps } from './TimelineItem'; import { Positioning } from '../models/positioning'; import { convertToCssVariable, StyleConfig } from '../models/style'; +import { isElement } from '../helper/element'; export type TimelineProps = { items: TimelineItemsProps; @@ -11,6 +12,7 @@ export type TimelineProps = { formatDate?: (date: Date) => string; customMarker?: ReactElement; customPointer?: ReactElement; + customTimelineEnds?: ReactElement | { opening?: ReactElement; closing?: ReactElement }; styleConfig?: StyleConfig; className?: string; }; @@ -21,7 +23,7 @@ export const defaultTimelineConfig: Partial = { }; export function Timeline(props: TimelineProps) { - const { items, positioning, minMarkerGap, className, customMarker, customPointer, styleConfig, formatDate } = { + const { items, positioning, minMarkerGap, className, customMarker, customPointer, customTimelineEnds, styleConfig, formatDate } = { ...defaultTimelineConfig, ...props, }; @@ -128,7 +130,14 @@ export function Timeline(props: TimelineProps) { ))}
-
+
+
+ {isElement(customTimelineEnds) ? customTimelineEnds : customTimelineEnds?.opening} +
+
+ {isElement(customTimelineEnds) ? customTimelineEnds : customTimelineEnds?.closing} +
+
diff --git a/src/helper/element.ts b/src/helper/element.ts new file mode 100644 index 0000000..c50157e --- /dev/null +++ b/src/helper/element.ts @@ -0,0 +1,3 @@ +import { ReactElement, isValidElement } from 'react'; + +export const isElement = (element: any): element is ReactElement => isValidElement(element); diff --git a/src/helper/object.ts b/src/helper/object.ts index 90529fd..d9765e6 100644 --- a/src/helper/object.ts +++ b/src/helper/object.ts @@ -1,4 +1,8 @@ -export const flattenObject = , T extends O | string | undefined>(ob: Record, delimiter?: string) => { +export const flattenObject = , T extends O | string | undefined>( + ob: Record, + delimiter?: string, + keyTransformer: (key: string) => string = (key) => key, +) => { const result: Record = {}; Object.keys(ob).forEach((i) => { @@ -6,12 +10,18 @@ export const flattenObject = , T extends O | string if (typeof value === 'object') { const temp = flattenObject(value, delimiter); Object.keys(temp).forEach((j) => { - result[`${i}${delimiter ?? '.'}${j}`] = temp[j]; + result[`${keyTransformer(i)}${delimiter ?? '.'}${j}`] = temp[j]; }); } else if (value) { - result[i] = value; + result[keyTransformer(i)] = value; } }); return result; }; + +export const camelCaseToKebabCase = (value: string) => + value + .split(/(?=[A-Z])/g) + .join('-') + .toLowerCase(); diff --git a/src/models/style.ts b/src/models/style.ts index 476f37f..4637981 100644 --- a/src/models/style.ts +++ b/src/models/style.ts @@ -1,36 +1,40 @@ -import { flattenObject } from '../helper/object'; +import { CSSProperties } from 'react'; +import { camelCaseToKebabCase, flattenObject } from '../helper/object'; export type StyleConfig = { line?: { - width?: string; - color?: string; + width?: CSSProperties['width']; + color?: CSSProperties['backgroundColor']; + overhang?: CSSProperties['paddingBlock']; }; - gap?: string; - offset?: { - left?: string; - right?: string; + item?: { + gap?: CSSProperties['gap']; + startOffset?: { + left?: CSSProperties['marginTop']; + right?: CSSProperties['marginTop']; + }; }; marker?: { - size?: string; - color?: string; - radius?: string; + size?: CSSProperties['width']; + color?: CSSProperties['backgroundColor']; + radius?: CSSProperties['borderRadius']; }; pointer?: { - height?: string; - width?: string; - offset?: string; + height?: CSSProperties['height']; + width?: CSSProperties['width']; + minOffset?: CSSProperties['marginTop']; }; card?: { - background?: string; - radius?: string; - offset?: string; - shadow?: string; - padding?: string; + background?: CSSProperties['backgroundColor']; + radius?: CSSProperties['borderRadius']; + shadow?: CSSProperties['boxShadow']; + padding?: CSSProperties['padding']; + offset?: CSSProperties['gap']; }; }; export const convertToCssVariable = (styleConfig: StyleConfig) => - Object.entries(flattenObject(styleConfig, '-')).reduce( - (prev: Record, [key, value]) => ({ ...prev, [`--${key}`]: value }), + Object.entries(flattenObject(styleConfig, '-', camelCaseToKebabCase)).reduce( + (prev: Record, [key, value]) => ({ ...prev, [`--${key}`]: typeof value === 'number' ? `${value}px` : value }), {}, );