@helpwave/hightide 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +24 -0
- package/.storybook/preview.tsx +67 -0
- package/LICENSE +373 -0
- package/README.md +8 -0
- package/coloring/shading.ts +46 -0
- package/coloring/types.ts +13 -0
- package/components/Avatar.tsx +58 -0
- package/components/AvatarGroup.tsx +48 -0
- package/components/BreadCrumb.tsx +35 -0
- package/components/Button.tsx +236 -0
- package/components/ChipList.tsx +89 -0
- package/components/Circle.tsx +27 -0
- package/components/ErrorComponent.tsx +40 -0
- package/components/Expandable.tsx +61 -0
- package/components/HelpwaveBadge.tsx +35 -0
- package/components/HideableContentSection.tsx +43 -0
- package/components/InputGroup.tsx +72 -0
- package/components/LoadingAndErrorComponent.tsx +47 -0
- package/components/LoadingAnimation.tsx +40 -0
- package/components/LoadingButton.tsx +27 -0
- package/components/MarkdownInterpreter.tsx +278 -0
- package/components/Pagination.tsx +65 -0
- package/components/Profile.tsx +124 -0
- package/components/ProgressIndicator.tsx +58 -0
- package/components/Ring.tsx +286 -0
- package/components/SearchableList.tsx +69 -0
- package/components/SortButton.tsx +33 -0
- package/components/Span.tsx +0 -0
- package/components/StepperBar.tsx +124 -0
- package/components/Table.tsx +330 -0
- package/components/TechRadar.tsx +247 -0
- package/components/TextImage.tsx +86 -0
- package/components/TimeDisplay.tsx +121 -0
- package/components/Tooltip.tsx +92 -0
- package/components/VerticalDivider.tsx +51 -0
- package/components/date/DatePicker.tsx +164 -0
- package/components/date/DayPicker.tsx +95 -0
- package/components/date/TimePicker.tsx +167 -0
- package/components/date/YearMonthPicker.tsx +130 -0
- package/components/examples/InputGroupExample.tsx +58 -0
- package/components/examples/MultiSelectExample.tsx +57 -0
- package/components/examples/SearchableSelectExample.tsx +34 -0
- package/components/examples/SelectExample.tsx +28 -0
- package/components/examples/StackingModals.tsx +54 -0
- package/components/examples/TableExample.tsx +159 -0
- package/components/examples/TextareaExample.tsx +23 -0
- package/components/examples/TileExample.tsx +25 -0
- package/components/examples/Title.tsx +0 -0
- package/components/examples/date/DateTimePickerExample.tsx +53 -0
- package/components/examples/properties/CheckboxPropertyExample.tsx +29 -0
- package/components/examples/properties/DatePropertyExample.tsx +44 -0
- package/components/examples/properties/MultiSelectPropertyExample.tsx +39 -0
- package/components/examples/properties/NumberPropertyExample.tsx +28 -0
- package/components/examples/properties/SelectPropertyExample.tsx +39 -0
- package/components/examples/properties/TextPropertyExample.tsx +30 -0
- package/components/icons/Helpwave.tsx +51 -0
- package/components/icons/Tag.tsx +29 -0
- package/components/layout/Carousel.tsx +396 -0
- package/components/layout/DividerInserter.tsx +37 -0
- package/components/layout/FAQSection.tsx +57 -0
- package/components/layout/Tile.tsx +67 -0
- package/components/modals/ConfirmDialog.tsx +105 -0
- package/components/modals/DiscardChangesDialog.tsx +71 -0
- package/components/modals/InputModal.tsx +26 -0
- package/components/modals/LanguageModal.tsx +76 -0
- package/components/modals/Modal.tsx +149 -0
- package/components/modals/ModalRegister.tsx +45 -0
- package/components/properties/CheckboxProperty.tsx +62 -0
- package/components/properties/DateProperty.tsx +58 -0
- package/components/properties/MultiSelectProperty.tsx +82 -0
- package/components/properties/NumberProperty.tsx +86 -0
- package/components/properties/PropertyBase.tsx +84 -0
- package/components/properties/SelectProperty.tsx +67 -0
- package/components/properties/TextProperty.tsx +81 -0
- package/components/user-input/Checkbox.tsx +139 -0
- package/components/user-input/DateAndTimePicker.tsx +156 -0
- package/components/user-input/Input.tsx +192 -0
- package/components/user-input/Label.tsx +32 -0
- package/components/user-input/Menu.tsx +75 -0
- package/components/user-input/MultiSelect.tsx +158 -0
- package/components/user-input/ScrollPicker.tsx +240 -0
- package/components/user-input/SearchableSelect.tsx +36 -0
- package/components/user-input/Select.tsx +132 -0
- package/components/user-input/Textarea.tsx +86 -0
- package/components/user-input/ToggleableInput.tsx +115 -0
- package/eslint.config.js +3 -0
- package/globals.css +488 -0
- package/hooks/useHoverState.ts +88 -0
- package/hooks/useLanguage.tsx +78 -0
- package/hooks/useLocalStorage.tsx +33 -0
- package/hooks/useOutsideClick.ts +25 -0
- package/hooks/useSaveDelay.ts +46 -0
- package/hooks/useTheme.tsx +57 -0
- package/hooks/useTranslation.ts +43 -0
- package/index.ts +0 -0
- package/package.json +71 -0
- package/postcss.config.mjs +7 -0
- package/stories/README.md +23 -0
- package/stories/coloring/shading.stories.tsx +54 -0
- package/stories/geometry/Circle.stories.tsx +16 -0
- package/stories/geometry/rings/AnimatedRing.stories.tsx +18 -0
- package/stories/geometry/rings/RadialRings.stories.tsx +19 -0
- package/stories/geometry/rings/Ring.stories.tsx +17 -0
- package/stories/geometry/rings/RingWave.stories.tsx +20 -0
- package/stories/layout/FAQSection.stories.tsx +49 -0
- package/stories/layout/InputGroup.stories.tsx +19 -0
- package/stories/layout/Table.stories.tsx +19 -0
- package/stories/layout/TextImage.stories.tsx +24 -0
- package/stories/layout/chip/Chip.stories.tsx +19 -0
- package/stories/layout/chip/ChipList.stories.tsx +27 -0
- package/stories/layout/tile/Tile.stories.ts +20 -0
- package/stories/layout/tile/TileWithImage.stories.tsx +27 -0
- package/stories/other/BreadCrumbs.stories.tsx +21 -0
- package/stories/other/HelpwaveBadge.stories.tsx +18 -0
- package/stories/other/HelpwaveSpinner.stories.tsx +19 -0
- package/stories/other/MarkdownInterpreter.stories.tsx +18 -0
- package/stories/other/Profile.stories.tsx +52 -0
- package/stories/other/SearchableList.stories.tsx +21 -0
- package/stories/other/StackingModals.stories.tsx +16 -0
- package/stories/other/TechRadar.stories.tsx +14 -0
- package/stories/other/Translation.stories.tsx +56 -0
- package/stories/other/VerticalDivider.stories.tsx +20 -0
- package/stories/other/avatar/Avatar.stories.tsx +19 -0
- package/stories/other/avatar/AvatarGroup.stories.tsx +26 -0
- package/stories/other/tooltip/Tooltip.stories.tsx +30 -0
- package/stories/other/tooltip/TooltipStack.stories.tsx +39 -0
- package/stories/user-action/button/LoadingButton.stories.tsx +21 -0
- package/stories/user-action/button/OutlineButton.stories.tsx +22 -0
- package/stories/user-action/button/SolidButton.stories.tsx +22 -0
- package/stories/user-action/button/TextButton.stories.tsx +22 -0
- package/stories/user-action/input/Checkbox.stories.tsx +20 -0
- package/stories/user-action/input/Label.stories.tsx +18 -0
- package/stories/user-action/input/ScrollPicker.stories.tsx +20 -0
- package/stories/user-action/input/Textarea.stories.tsx +22 -0
- package/stories/user-action/input/date/DatePicker.stories.tsx +23 -0
- package/stories/user-action/input/date/DateTimePicker.stories.tsx +26 -0
- package/stories/user-action/input/date/DayPicker.stories.tsx +20 -0
- package/stories/user-action/input/date/TimePicker.stories.tsx +20 -0
- package/stories/user-action/input/date/YearMonthPicker.stories.tsx +21 -0
- package/stories/user-action/input/select/MultiSelect.stories.tsx +39 -0
- package/stories/user-action/input/select/SearchableSelect.stories.tsx +32 -0
- package/stories/user-action/input/select/Select.stories.tsx +30 -0
- package/stories/user-action/properties/CheckboxProperty.stories.tsx +20 -0
- package/stories/user-action/properties/DateProperty.stories.tsx +21 -0
- package/stories/user-action/properties/MultiSelectProperty.stories.tsx +33 -0
- package/stories/user-action/properties/NumberProperty.stories.tsx +21 -0
- package/stories/user-action/properties/PropertyBase.stories.tsx +28 -0
- package/stories/user-action/properties/SingleSelectProperty.stories.tsx +35 -0
- package/stories/user-action/properties/TextProperty.stories.tsx +20 -0
- package/tsconfig.json +20 -0
- package/util/array.ts +115 -0
- package/util/builder.ts +9 -0
- package/util/date.ts +180 -0
- package/util/easeFunctions.ts +37 -0
- package/util/emailValidation.ts +3 -0
- package/util/loopingArray.ts +94 -0
- package/util/math.ts +3 -0
- package/util/news.ts +43 -0
- package/util/noop.ts +1 -0
- package/util/simpleSearch.ts +65 -0
- package/util/storage.ts +37 -0
- package/util/types.ts +4 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { DatePropertyProps } from '../../properties/DateProperty'
|
|
3
|
+
import { DateProperty } from '../../properties/DateProperty'
|
|
4
|
+
import { noop } from '../../../util/noop'
|
|
5
|
+
|
|
6
|
+
export type DatePropertyExampleProps = DatePropertyProps & {
|
|
7
|
+
readOnly: boolean,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Example for using the DateProperty
|
|
12
|
+
*/
|
|
13
|
+
export const DatePropertyExample = ({
|
|
14
|
+
value,
|
|
15
|
+
onChange = noop,
|
|
16
|
+
onRemove = noop,
|
|
17
|
+
onEditComplete = noop,
|
|
18
|
+
...restProps
|
|
19
|
+
}: DatePropertyExampleProps) => {
|
|
20
|
+
const [usedDate, setUsedDate] = useState<Date | undefined>(value)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setUsedDate(value)
|
|
24
|
+
}, [value])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<DateProperty
|
|
28
|
+
{...restProps}
|
|
29
|
+
onChange={date => {
|
|
30
|
+
setUsedDate(date)
|
|
31
|
+
onChange(date)
|
|
32
|
+
}}
|
|
33
|
+
onEditComplete={date => {
|
|
34
|
+
setUsedDate(date)
|
|
35
|
+
onEditComplete(date)
|
|
36
|
+
}}
|
|
37
|
+
onRemove={() => {
|
|
38
|
+
setUsedDate(undefined)
|
|
39
|
+
onRemove()
|
|
40
|
+
}}
|
|
41
|
+
value={usedDate}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { MultiSelectOption } from '../../user-input/MultiSelect'
|
|
3
|
+
import type { MultiSelectPropertyProps } from '../../properties/MultiSelectProperty'
|
|
4
|
+
import { MultiSelectProperty } from '../../properties/MultiSelectProperty'
|
|
5
|
+
|
|
6
|
+
export type MultiSelectPropertyExample = Omit<MultiSelectPropertyProps<string>, 'onChange' | 'onRemove' | 'search' | 'selectedDisplay' > & {
|
|
7
|
+
enableSearch: boolean,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Example for using the MultiSelectProperty
|
|
12
|
+
*/
|
|
13
|
+
export const MultiSelectPropertyExample = ({
|
|
14
|
+
options,
|
|
15
|
+
hintText,
|
|
16
|
+
enableSearch,
|
|
17
|
+
...restProps
|
|
18
|
+
}: MultiSelectPropertyExample) => {
|
|
19
|
+
const [usedOptions, setUsedOptions] = useState<MultiSelectOption<string>[]>(options)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setUsedOptions(options)
|
|
23
|
+
}, [options])
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setUsedOptions(options.map(value => ({ ...value, selected: false })))
|
|
27
|
+
}, [hintText, options])
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<MultiSelectProperty
|
|
31
|
+
{...restProps}
|
|
32
|
+
options={usedOptions}
|
|
33
|
+
search={enableSearch ? { initialSearch: '', searchMapping: value => [value.label] } : undefined}
|
|
34
|
+
onChange={setUsedOptions}
|
|
35
|
+
onRemove={() => setUsedOptions(usedOptions.map(value => ({ ...value, selected: false })))}
|
|
36
|
+
hintText={hintText}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { NumberPropertyProps } from '../../properties/NumberProperty'
|
|
3
|
+
import { NumberProperty } from '../../properties/NumberProperty'
|
|
4
|
+
|
|
5
|
+
export type NumberPropertyExampleProps = Omit<NumberPropertyProps, 'onChange' | 'onRemove'>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Example for using the NumberProperty
|
|
9
|
+
*/
|
|
10
|
+
export const NumberPropertyExample = ({
|
|
11
|
+
value,
|
|
12
|
+
...restProps
|
|
13
|
+
}: NumberPropertyExampleProps) => {
|
|
14
|
+
const [usedValue, setUsedValue] = useState<number | undefined>(value)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setUsedValue(value)
|
|
18
|
+
}, [value])
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<NumberProperty
|
|
22
|
+
{...restProps}
|
|
23
|
+
onChange={setUsedValue}
|
|
24
|
+
onRemove={() => setUsedValue(undefined)}
|
|
25
|
+
value={usedValue}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { SingleSelectPropertyProps } from '../../properties/SelectProperty'
|
|
3
|
+
import { SingleSelectProperty } from '../../properties/SelectProperty'
|
|
4
|
+
|
|
5
|
+
export type SingleSelectPropertyExample = Omit<SingleSelectPropertyProps<string>, 'onChange' | 'onRemove'|'searchMapping'>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Example for using the SingleSelectProperty
|
|
9
|
+
*/
|
|
10
|
+
export const SingleSelectPropertyExample = ({
|
|
11
|
+
value,
|
|
12
|
+
options,
|
|
13
|
+
hintText,
|
|
14
|
+
...restProps
|
|
15
|
+
}: SingleSelectPropertyExample) => {
|
|
16
|
+
const [usedValue, setUsedValue] = useState<string | undefined>(value)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setUsedValue(undefined)
|
|
20
|
+
}, [hintText])
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (options.find(value1 => value1.value === value)) {
|
|
24
|
+
setUsedValue(value)
|
|
25
|
+
}
|
|
26
|
+
}, [value, options])
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<SingleSelectProperty
|
|
30
|
+
{...restProps}
|
|
31
|
+
value={usedValue}
|
|
32
|
+
options={options}
|
|
33
|
+
searchMapping={value1 => [value1.value]}
|
|
34
|
+
onChange={setUsedValue}
|
|
35
|
+
onRemove={() => setUsedValue(undefined)}
|
|
36
|
+
hintText={hintText}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { TextPropertyProps } from '../../properties/TextProperty'
|
|
3
|
+
import { TextProperty } from '../../properties/TextProperty'
|
|
4
|
+
|
|
5
|
+
export type TextPropertyExampleProps = Omit<TextPropertyProps, 'onChange' | 'onRemove'> & {
|
|
6
|
+
readOnly: boolean,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Example for using the TextProperty
|
|
11
|
+
*/
|
|
12
|
+
export const TextPropertyExample = ({
|
|
13
|
+
value,
|
|
14
|
+
...restProps
|
|
15
|
+
}: TextPropertyExampleProps) => {
|
|
16
|
+
const [usedValue, setUsedValue] = useState<string | undefined>(value)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setUsedValue(value)
|
|
20
|
+
}, [value])
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<TextProperty
|
|
24
|
+
{...restProps}
|
|
25
|
+
onChange={setUsedValue}
|
|
26
|
+
onRemove={() => setUsedValue(undefined)}
|
|
27
|
+
value={usedValue}
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SVGProps } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
|
|
4
|
+
export type HelpwaveProps = SVGProps<SVGSVGElement> & {
|
|
5
|
+
color?: string,
|
|
6
|
+
animate?: 'none' | 'loading' | 'pulse' | 'bounce',
|
|
7
|
+
size?: number,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The helpwave loading spinner based on the svg logo.
|
|
12
|
+
*/
|
|
13
|
+
export const Helpwave = ({
|
|
14
|
+
color = 'currentColor',
|
|
15
|
+
animate = 'none',
|
|
16
|
+
size = 64,
|
|
17
|
+
...props
|
|
18
|
+
}: HelpwaveProps) => {
|
|
19
|
+
const isLoadingAnimation = animate === 'loading'
|
|
20
|
+
let svgAnimationKey = ''
|
|
21
|
+
|
|
22
|
+
if (animate === 'pulse') {
|
|
23
|
+
svgAnimationKey = 'animate-pulse'
|
|
24
|
+
} else if (animate === 'bounce') {
|
|
25
|
+
svgAnimationKey = 'animate-bounce'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (size < 0) {
|
|
29
|
+
console.error('size cannot be less than 0')
|
|
30
|
+
size = 64
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<svg
|
|
35
|
+
width={size}
|
|
36
|
+
height={size}
|
|
37
|
+
viewBox="0 0 888 888"
|
|
38
|
+
fill="none"
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeWidth={48}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
<g className={clsx(svgAnimationKey)}>
|
|
44
|
+
<path className={clsx({ 'animate-wave-big-left-up': isLoadingAnimation })} d="M144 543.235C144 423.259 232.164 326 340.92 326" stroke={color} strokeDasharray="1000" />
|
|
45
|
+
<path className={clsx({ 'animate-wave-big-right-down': isLoadingAnimation })} d="M537.84 544.104C429.084 544.104 340.92 446.844 340.92 326.869" stroke={color} strokeDasharray="1000" />
|
|
46
|
+
<path className={clsx({ 'animate-wave-small-left-up': isLoadingAnimation })} d="M462.223 518.035C462.223 432.133 525.348 362.495 603.217 362.495" stroke={color} strokeDasharray="1000" />
|
|
47
|
+
<path className={clsx({ 'animate-wave-small-right-down': isLoadingAnimation })} d="M745.001 519.773C666.696 519.773 603.218 450.136 603.218 364.233" stroke={color} strokeDasharray="1000" />
|
|
48
|
+
</g>
|
|
49
|
+
</svg>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ImageProps } from 'next/image'
|
|
2
|
+
import Image from 'next/image'
|
|
3
|
+
|
|
4
|
+
export type TagProps = Omit<ImageProps, 'src'|'alt'>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tag icon from flaticon
|
|
8
|
+
*
|
|
9
|
+
* https://www.flaticon.com/free-icon/price-tag_721550?term=label&page=1&position=8&origin=tag&related_id=721550
|
|
10
|
+
*
|
|
11
|
+
* When using it make attribution
|
|
12
|
+
*/
|
|
13
|
+
export const TagIcon = ({
|
|
14
|
+
className,
|
|
15
|
+
width = 16,
|
|
16
|
+
height = 16,
|
|
17
|
+
...props
|
|
18
|
+
}: TagProps) => {
|
|
19
|
+
return (
|
|
20
|
+
<Image
|
|
21
|
+
{...props}
|
|
22
|
+
width={width}
|
|
23
|
+
height={height}
|
|
24
|
+
alt=""
|
|
25
|
+
src="https://cdn.helpwave.de/icons/label.png"
|
|
26
|
+
className={className}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
6
|
+
import { createLoopingListWithIndex, range } from '../../util/array'
|
|
7
|
+
import { clamp } from '../../util/math'
|
|
8
|
+
import { EaseFunctions } from '../../util/easeFunctions'
|
|
9
|
+
import type { Direction } from '../../util/loopingArray'
|
|
10
|
+
import { LoopingArrayCalculator } from '../../util/loopingArray'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
type CarouselProps = {
|
|
14
|
+
children: ReactNode[],
|
|
15
|
+
animationTime?: number,
|
|
16
|
+
isLooping?: boolean,
|
|
17
|
+
isAutoLooping?: boolean,
|
|
18
|
+
autoLoopingTimeOut?: number,
|
|
19
|
+
autoLoopAnimationTime?: number,
|
|
20
|
+
hintNext?: boolean,
|
|
21
|
+
arrows?: boolean,
|
|
22
|
+
dots?: boolean,
|
|
23
|
+
/**
|
|
24
|
+
* Percentage that is allowed to be scrolled further
|
|
25
|
+
*/
|
|
26
|
+
overScrollThreshold?: number,
|
|
27
|
+
blurColor?: string,
|
|
28
|
+
className?: string,
|
|
29
|
+
heightClassName?: string,
|
|
30
|
+
widthClassName?: string,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ItemType = {
|
|
34
|
+
item: ReactNode,
|
|
35
|
+
index: number,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type CarouselAnimationState = {
|
|
39
|
+
targetPosition: number,
|
|
40
|
+
/**
|
|
41
|
+
* Value of either 1 or -1, 1 is forwards -1 is backwards
|
|
42
|
+
*/
|
|
43
|
+
direction: Direction,
|
|
44
|
+
startPosition: number,
|
|
45
|
+
startTime?: number,
|
|
46
|
+
lastUpdateTime?: number,
|
|
47
|
+
isAutoLooping: boolean,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type DragState = {
|
|
51
|
+
startX: number,
|
|
52
|
+
startTime: number,
|
|
53
|
+
lastX: number,
|
|
54
|
+
startIndex: number,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type CarouselInformation = {
|
|
58
|
+
currentPosition: number,
|
|
59
|
+
dragState?: DragState,
|
|
60
|
+
animationState?: CarouselAnimationState,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const Carousel = ({
|
|
64
|
+
children,
|
|
65
|
+
animationTime = 200,
|
|
66
|
+
isLooping = false,
|
|
67
|
+
isAutoLooping = false,
|
|
68
|
+
autoLoopingTimeOut = 5000,
|
|
69
|
+
autoLoopAnimationTime = 500,
|
|
70
|
+
hintNext = false,
|
|
71
|
+
arrows = false,
|
|
72
|
+
dots = true,
|
|
73
|
+
overScrollThreshold = 0.1,
|
|
74
|
+
blurColor = 'from-white',
|
|
75
|
+
className = '',
|
|
76
|
+
heightClassName = 'h-[24rem]',
|
|
77
|
+
widthClassName = 'w-[70%] desktop:w-1/2',
|
|
78
|
+
}: CarouselProps) => {
|
|
79
|
+
if (isAutoLooping && !isLooping) {
|
|
80
|
+
console.error('When isAutoLooping is true, isLooping should also be true')
|
|
81
|
+
isLooping = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const [{
|
|
85
|
+
currentPosition,
|
|
86
|
+
dragState,
|
|
87
|
+
animationState,
|
|
88
|
+
}, setCarouselInformation] = useState<CarouselInformation>({
|
|
89
|
+
currentPosition: 0,
|
|
90
|
+
})
|
|
91
|
+
const animationId = useRef<number | undefined>(undefined)
|
|
92
|
+
const timeOut = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
93
|
+
autoLoopingTimeOut = Math.max(0, autoLoopingTimeOut)
|
|
94
|
+
|
|
95
|
+
const length = children.length
|
|
96
|
+
const paddingItemCount = 3 // The number of items to append left and right of the list to allow for clean transition when looping
|
|
97
|
+
|
|
98
|
+
const util = useMemo(() => new LoopingArrayCalculator(length, isLooping, overScrollThreshold), [length, isLooping, overScrollThreshold])
|
|
99
|
+
const currentIndex = util.getCorrectedPosition(LoopingArrayCalculator.withoutOffset(currentPosition))
|
|
100
|
+
animationTime = Math.max(200, animationTime) // in ms, must be > 0
|
|
101
|
+
autoLoopAnimationTime = Math.max(200, autoLoopAnimationTime)
|
|
102
|
+
|
|
103
|
+
const getStyleOffset = (index: number) => {
|
|
104
|
+
const baseOffset = -50 + (index - currentPosition) * 100
|
|
105
|
+
return `${baseOffset}%`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const animation = useCallback((time: number) => {
|
|
109
|
+
let keepAnimating: boolean = true
|
|
110
|
+
|
|
111
|
+
// Other calculation in the setState call to avoid updating the useCallback to often
|
|
112
|
+
setCarouselInformation((state) => {
|
|
113
|
+
const {
|
|
114
|
+
animationState,
|
|
115
|
+
dragState
|
|
116
|
+
} = state
|
|
117
|
+
if (animationState === undefined || dragState !== undefined) {
|
|
118
|
+
keepAnimating = false
|
|
119
|
+
return state
|
|
120
|
+
}
|
|
121
|
+
if (!animationState.startTime || !animationState.lastUpdateTime) {
|
|
122
|
+
return {
|
|
123
|
+
...state,
|
|
124
|
+
animationState: {
|
|
125
|
+
...animationState,
|
|
126
|
+
startTime: time,
|
|
127
|
+
lastUpdateTime: time
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const useAnimationTime = animationState.isAutoLooping ? autoLoopAnimationTime : animationTime
|
|
132
|
+
const progress = clamp((time - animationState.startTime) / useAnimationTime) // progress
|
|
133
|
+
const easedProgress = EaseFunctions.easeInEaseOut(progress)
|
|
134
|
+
const distance = util.getDistanceDirectional(animationState.startPosition, animationState.targetPosition, animationState.direction)
|
|
135
|
+
const newPosition = util.getCorrectedPosition(easedProgress * distance * animationState.direction + animationState.startPosition)
|
|
136
|
+
|
|
137
|
+
if (animationState.targetPosition === newPosition || progress === 1) {
|
|
138
|
+
keepAnimating = false
|
|
139
|
+
return ({
|
|
140
|
+
currentPosition: LoopingArrayCalculator.withoutOffset(newPosition),
|
|
141
|
+
animationState: undefined
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
return ({
|
|
145
|
+
currentPosition: newPosition,
|
|
146
|
+
animationState: {
|
|
147
|
+
...animationState!,
|
|
148
|
+
lastUpdateTime: time
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
if (keepAnimating) {
|
|
153
|
+
animationId.current = requestAnimationFrame(time1 => animation(time1))
|
|
154
|
+
}
|
|
155
|
+
}, [animationTime, autoLoopAnimationTime, util])
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (animationState) {
|
|
159
|
+
animationId.current = requestAnimationFrame(animation)
|
|
160
|
+
}
|
|
161
|
+
return () => {
|
|
162
|
+
if (animationId.current) {
|
|
163
|
+
cancelAnimationFrame(animationId.current)
|
|
164
|
+
animationId.current = 0
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}, [animationState]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
168
|
+
|
|
169
|
+
const startAutoLoop = () => setCarouselInformation(prevState => ({
|
|
170
|
+
...prevState,
|
|
171
|
+
dragState: prevState.dragState,
|
|
172
|
+
animationState: prevState.animationState || prevState.dragState ? prevState.animationState : {
|
|
173
|
+
startPosition: currentPosition,
|
|
174
|
+
targetPosition: (currentPosition + 1) % length,
|
|
175
|
+
direction: 1, // always move forward
|
|
176
|
+
isAutoLooping: true
|
|
177
|
+
}
|
|
178
|
+
}))
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!animationId.current && !animationState && !dragState && !timeOut.current) {
|
|
182
|
+
if (autoLoopingTimeOut > 0) {
|
|
183
|
+
timeOut.current = setTimeout(() => {
|
|
184
|
+
startAutoLoop()
|
|
185
|
+
timeOut.current = undefined
|
|
186
|
+
}, autoLoopingTimeOut)
|
|
187
|
+
} else {
|
|
188
|
+
startAutoLoop()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}, [animationState, dragState, animationId.current, timeOut.current]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
192
|
+
|
|
193
|
+
const startAnimation = (targetPosition?: number) => {
|
|
194
|
+
if (targetPosition === undefined) {
|
|
195
|
+
targetPosition = LoopingArrayCalculator.withoutOffset(currentPosition)
|
|
196
|
+
}
|
|
197
|
+
if (targetPosition === currentPosition) {
|
|
198
|
+
return // we are exactly where we want to be
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// find target index and fastest path to it
|
|
202
|
+
const direction = util.getBestDirection(currentPosition, targetPosition)
|
|
203
|
+
clearTimeout(timeOut.current)
|
|
204
|
+
timeOut.current = undefined
|
|
205
|
+
if (animationId.current) {
|
|
206
|
+
cancelAnimationFrame(animationId.current)
|
|
207
|
+
animationId.current = undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setCarouselInformation(prevState => ({
|
|
211
|
+
...prevState,
|
|
212
|
+
dragState: undefined,
|
|
213
|
+
animationState: {
|
|
214
|
+
targetPosition: targetPosition!,
|
|
215
|
+
direction,
|
|
216
|
+
startPosition: currentPosition,
|
|
217
|
+
isAutoLooping: false
|
|
218
|
+
},
|
|
219
|
+
timeOut: undefined
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const canGoLeft = () => {
|
|
224
|
+
return isLooping || currentPosition !== 0
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const canGoRight = () => {
|
|
228
|
+
return isLooping || currentPosition !== length - 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const left = () => {
|
|
232
|
+
if (canGoLeft()) {
|
|
233
|
+
startAnimation(currentPosition === 0 ? length - 1 : LoopingArrayCalculator.withoutOffset(currentPosition - 1))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const right = () => {
|
|
238
|
+
if (canGoRight()) {
|
|
239
|
+
startAnimation(LoopingArrayCalculator.withoutOffset((currentPosition + 1) % length))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let items: ItemType[] = children.map((item, index) => ({
|
|
244
|
+
index,
|
|
245
|
+
item
|
|
246
|
+
}))
|
|
247
|
+
|
|
248
|
+
if (isLooping) {
|
|
249
|
+
const before = createLoopingListWithIndex(children, length - 1, paddingItemCount, false).reverse().map(([index, item]) => ({
|
|
250
|
+
index,
|
|
251
|
+
item
|
|
252
|
+
}))
|
|
253
|
+
const after = createLoopingListWithIndex(children, 0, paddingItemCount).map(([index, item]) => ({
|
|
254
|
+
index,
|
|
255
|
+
item
|
|
256
|
+
}))
|
|
257
|
+
items = [
|
|
258
|
+
...before,
|
|
259
|
+
...items,
|
|
260
|
+
...after
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const onDragStart = (x: number) => setCarouselInformation(prevState => ({
|
|
265
|
+
...prevState,
|
|
266
|
+
dragState: {
|
|
267
|
+
lastX: x,
|
|
268
|
+
startX: x,
|
|
269
|
+
startTime: Date.now(),
|
|
270
|
+
startIndex: currentPosition,
|
|
271
|
+
},
|
|
272
|
+
animationState: undefined // cancel animation
|
|
273
|
+
}))
|
|
274
|
+
|
|
275
|
+
const onDrag = (x: number, width: number) => {
|
|
276
|
+
// For some weird reason the clientX is 0 on the last dragUpdate before drag end causing issues
|
|
277
|
+
if (!dragState || x === 0) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
const offsetUpdate = (dragState.lastX - x) / width
|
|
281
|
+
const newPosition = util.getCorrectedPosition(currentPosition + offsetUpdate)
|
|
282
|
+
|
|
283
|
+
setCarouselInformation(prevState => ({
|
|
284
|
+
...prevState,
|
|
285
|
+
currentPosition: newPosition,
|
|
286
|
+
dragState: {
|
|
287
|
+
...dragState,
|
|
288
|
+
lastX: x
|
|
289
|
+
},
|
|
290
|
+
}))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const onDragEnd = (x: number, width: number) => {
|
|
294
|
+
if (!dragState) {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const distance = dragState.startX - x
|
|
298
|
+
const relativeDistance = distance / width
|
|
299
|
+
const duration = (Date.now() - dragState.startTime) // in milliseconds
|
|
300
|
+
const velocity = distance / (Date.now() - dragState.startTime)
|
|
301
|
+
|
|
302
|
+
const isSlide = Math.abs(velocity) > 2 || (duration < 200 && (Math.abs(relativeDistance) > 0.2 || Math.abs(distance) > 50))
|
|
303
|
+
if (isSlide) {
|
|
304
|
+
if (distance > 0 && canGoRight()) {
|
|
305
|
+
right()
|
|
306
|
+
return
|
|
307
|
+
} else if (distance < 0 && canGoLeft()) {
|
|
308
|
+
left()
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
startAnimation()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const dragHandlers = {
|
|
316
|
+
draggable: true,
|
|
317
|
+
onDragStart: (event: React.DragEvent<HTMLDivElement>) => {
|
|
318
|
+
onDragStart(event.clientX)
|
|
319
|
+
event.dataTransfer.setDragImage(document.createElement('div'), 0, 0)
|
|
320
|
+
},
|
|
321
|
+
onDrag: (event: React.DragEvent<HTMLDivElement>) => onDrag(event.clientX, (event.target as HTMLDivElement).getBoundingClientRect().width),
|
|
322
|
+
onDragEnd: (event: React.DragEvent<HTMLDivElement>) => onDragEnd(event.clientX, (event.target as HTMLDivElement).getBoundingClientRect().width),
|
|
323
|
+
onTouchStart: (event: React.TouchEvent<HTMLDivElement>) => onDragStart(event.touches[0]!.clientX),
|
|
324
|
+
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => onDrag(event.touches[0]!.clientX, (event.target as HTMLDivElement).getBoundingClientRect().width),
|
|
325
|
+
onTouchEnd: (event: React.TouchEvent<HTMLDivElement>) => onDragEnd(event.changedTouches[0]!.clientX, (event.target as HTMLDivElement).getBoundingClientRect().width),
|
|
326
|
+
onTouchCancel: (event: React.TouchEvent<HTMLDivElement>) => onDragEnd(event.changedTouches[0]!.clientX, (event.target as HTMLDivElement).getBoundingClientRect().width),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<div className="col items-center w-full gap-y-2">
|
|
331
|
+
<div className={clsx(`relative w-full overflow-hidden`, heightClassName, className)}>
|
|
332
|
+
{arrows && (
|
|
333
|
+
<>
|
|
334
|
+
<div
|
|
335
|
+
className={clsx('absolute z-10 left-0 top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-lg cursor-pointer border-black border-2', { hidden: !canGoLeft() })}
|
|
336
|
+
onClick={() => left()}
|
|
337
|
+
>
|
|
338
|
+
<ChevronLeft size={32}/>
|
|
339
|
+
</div>
|
|
340
|
+
<div
|
|
341
|
+
className={clsx('absolute z-10 right-0 top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-lg cursor-pointer border-black border-2', { hidden: !canGoRight() })}
|
|
342
|
+
onClick={() => right()}
|
|
343
|
+
>
|
|
344
|
+
<ChevronRight size={32}/>
|
|
345
|
+
</div>
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
{hintNext ? (
|
|
349
|
+
<div className={clsx(`relative row h-full`, heightClassName)}>
|
|
350
|
+
<div className="relative row h-full w-full px-2 overflow-hidden">
|
|
351
|
+
{items.map(({
|
|
352
|
+
item,
|
|
353
|
+
index
|
|
354
|
+
}, listIndex) => (
|
|
355
|
+
<div
|
|
356
|
+
key={listIndex}
|
|
357
|
+
className={clsx(`absolute left-[50%] h-full overflow-hidden`, widthClassName, { '!cursor-grabbing': !!dragState })}
|
|
358
|
+
style={{ translate: getStyleOffset(listIndex - (isLooping ? paddingItemCount : 0)) }}
|
|
359
|
+
{...dragHandlers}
|
|
360
|
+
onClick={() => startAnimation(index)}
|
|
361
|
+
>
|
|
362
|
+
{item}
|
|
363
|
+
</div>
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
<div
|
|
367
|
+
className={clsx(`hidden pointer-events-none desktop:block absolute left-0 h-full w-[20%] bg-gradient-to-r to-transparent`, blurColor)}
|
|
368
|
+
/>
|
|
369
|
+
<div
|
|
370
|
+
className={clsx(`hidden pointer-events-none desktop:block absolute right-0 h-full w-[20%] bg-gradient-to-l to-transparent`, blurColor)}
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
) : (
|
|
374
|
+
<div className={clsx('px-16 h-full', { '!cursor-grabbing': !!dragState })} {...dragHandlers}>
|
|
375
|
+
{children[currentIndex]}
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
{dots && (
|
|
380
|
+
<div
|
|
381
|
+
className="row items-center justify-center w-full my-2">
|
|
382
|
+
{range(0, length - 1).map(index => (
|
|
383
|
+
<button
|
|
384
|
+
key={index}
|
|
385
|
+
className={clsx('w-[2rem] min-w-[2rem] h-[0.75rem] min-h-[0.75rem] hover:bg-primary hover:brightness-90 first:rounded-l-md last:rounded-r-md', {
|
|
386
|
+
'bg-gray-200': currentIndex !== index,
|
|
387
|
+
'bg-primary': currentIndex === index
|
|
388
|
+
})}
|
|
389
|
+
onClick={() => startAnimation(index)}
|
|
390
|
+
/>
|
|
391
|
+
))}
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
</div>
|
|
395
|
+
)
|
|
396
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import clsx from 'clsx'
|
|
3
|
+
|
|
4
|
+
export type DividerInserterProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
5
|
+
children: ReactNode[],
|
|
6
|
+
divider: (index: number) => ReactNode,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A Component for inserting a divider in the middle of each child element
|
|
11
|
+
*
|
|
12
|
+
* undefined elements are removed
|
|
13
|
+
*/
|
|
14
|
+
export const DividerInserter = ({
|
|
15
|
+
children,
|
|
16
|
+
divider,
|
|
17
|
+
className,
|
|
18
|
+
...restProps
|
|
19
|
+
}: DividerInserterProps) => {
|
|
20
|
+
const nodes: ReactNode[] = []
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < children.length; index++) {
|
|
23
|
+
const element = children[index]
|
|
24
|
+
if (element !== undefined) {
|
|
25
|
+
nodes.push(element)
|
|
26
|
+
if (index < children.length - 1) {
|
|
27
|
+
nodes.push(divider(index))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className={clsx(className)} {...restProps}>
|
|
34
|
+
{nodes}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|