torch-glare 1.0.2
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/LICENSE +21 -0
- package/README.md +207 -0
- package/cli/bin/addComponent.js +278 -0
- package/cli/bin/addHooks.js +75 -0
- package/cli/bin/addLayout.js +71 -0
- package/cli/bin/addProvider.js +71 -0
- package/cli/bin/addUtils.js +74 -0
- package/cli/bin/cli.js +73 -0
- package/cli/bin/init/init.js +15 -0
- package/cli/bin/init/tailwindInit.js +174 -0
- package/cli/bin/update.js +147 -0
- package/lib/components/ActionButton.tsx +63 -0
- package/lib/components/ActionsGroup.tsx +34 -0
- package/lib/components/AlertDialog.tsx +211 -0
- package/lib/components/Badge.tsx +116 -0
- package/lib/components/BadgeField.tsx +192 -0
- package/lib/components/Button.tsx +277 -0
- package/lib/components/Card.tsx +63 -0
- package/lib/components/Checkbox.tsx +122 -0
- package/lib/components/CountBadge.tsx +54 -0
- package/lib/components/DatePicker.tsx +464 -0
- package/lib/components/Drawer.tsx +118 -0
- package/lib/components/DropdownMenu.tsx +399 -0
- package/lib/components/FieldHint.tsx +76 -0
- package/lib/components/ImageAttachment.tsx +180 -0
- package/lib/components/InnerLabelField.tsx +155 -0
- package/lib/components/Input.tsx +179 -0
- package/lib/components/InputField.tsx +147 -0
- package/lib/components/Label.tsx +107 -0
- package/lib/components/LabelField.tsx +75 -0
- package/lib/components/LabeledCheckBox.tsx +65 -0
- package/lib/components/LabeledRadio.tsx +45 -0
- package/lib/components/LinkButton.tsx +94 -0
- package/lib/components/LoginButton.tsx +56 -0
- package/lib/components/PasswordLevel.tsx +58 -0
- package/lib/components/Popover.tsx +274 -0
- package/lib/components/ProfileMenu.tsx +90 -0
- package/lib/components/Radio.tsx +77 -0
- package/lib/components/RadioCard.tsx +72 -0
- package/lib/components/RingLoading.tsx +190 -0
- package/lib/components/SearchField.tsx +49 -0
- package/lib/components/Select.tsx +417 -0
- package/lib/components/SlideDatePicker.tsx +120 -0
- package/lib/components/SpinLoading.tsx +190 -0
- package/lib/components/Switcher.tsx +56 -0
- package/lib/components/TabFormItem.tsx +158 -0
- package/lib/components/Table.tsx +395 -0
- package/lib/components/Textarea.tsx +108 -0
- package/lib/components/Tooltip.tsx +111 -0
- package/lib/components/TransparentLabel.tsx +72 -0
- package/lib/components/TreeDropDown.tsx +69 -0
- package/lib/hooks/MobileSlidePicker/components/Picker.tsx +218 -0
- package/lib/hooks/MobileSlidePicker/components/PickerColumn.tsx +238 -0
- package/lib/hooks/MobileSlidePicker/components/PickerItem.tsx +64 -0
- package/lib/hooks/MobileSlidePicker/index.ts +10 -0
- package/lib/hooks/useActiveTreeItem.tsx +61 -0
- package/lib/hooks/useClickOutside.tsx +20 -0
- package/lib/hooks/useResize.tsx +78 -0
- package/lib/layouts/CLayout.tsx +326 -0
- package/lib/layouts/FieldSection.tsx +64 -0
- package/lib/layouts/TreeSubLayout.tsx +187 -0
- package/lib/providers/ThemeProvider.tsx +99 -0
- package/lib/utils/cn.ts +6 -0
- package/lib/utils/convertImageFileToDataUrl.ts +17 -0
- package/lib/utils/resize.ts +35 -0
- package/lib/utils/types.ts +12 -0
- package/package.json +28 -0
- package/torch-glare.js +24 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { cva, VariantProps } from "class-variance-authority";
|
|
4
|
+
import { HTMLAttributes, ReactNode, useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
const TreeDropDownVariants = cva(["flex px-[6px] h-[40px] gap-2 justify-start items-center w-full",
|
|
7
|
+
"text-content-system-global-primary border-l-[2px] rtl:border-r-[2px] border-transparent outline-none",
|
|
8
|
+
"hover:bg-white-alpha-075 hover:border-black-300 hover:text-content-system-action-primary-hover hover:gap-[14px]",
|
|
9
|
+
"rounded-r-[4px] text-start whitespace-nowrap transition-all duration-150 ease-in-out",
|
|
10
|
+
], {
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
secondary: "",
|
|
14
|
+
default: ""
|
|
15
|
+
},
|
|
16
|
+
active: {
|
|
17
|
+
true: "hover:gap-[8px]"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
compoundVariants: [
|
|
21
|
+
{
|
|
22
|
+
active: true,
|
|
23
|
+
variant: "default",
|
|
24
|
+
className: [
|
|
25
|
+
"bg-background-system-action-primary-hover border-border-system-action-primary-hover [&_button]:bg-purple-alpha-15 hover:bg-background-system-action-primary-hover hover:border-border-system-action-primary-hover",
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
active: true,
|
|
30
|
+
variant: "secondary",
|
|
31
|
+
className: [
|
|
32
|
+
"bg-wavy-navy-1000 border-border-system-action-field-hover-selected [&_button]:bg-blue-sparkle-alpha-15 hover:bg-wavy-navy-1000 hover:border-border-system-action-field-hover-selected",
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
defaultVariants: {},//
|
|
37
|
+
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
interface Props extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof TreeDropDownVariants> {
|
|
41
|
+
theme?: "dark" | "light" | "default";
|
|
42
|
+
treeLabel: ReactNode;
|
|
43
|
+
open?: boolean;
|
|
44
|
+
variant?: "secondary" | "default";
|
|
45
|
+
childrenContainerClassName?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const TreeDropDown = ({ childrenContainerClassName, className, variant = "secondary", treeLabel, open, theme, ...props }: Props) => {
|
|
49
|
+
const [isActive, setIsActive] = useState(open);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setIsActive(open)
|
|
52
|
+
}, [open])
|
|
53
|
+
return (
|
|
54
|
+
<div {...props} className={cn("flex h-fit flex-col transition-all ease-in-out duration-500",)}>
|
|
55
|
+
<div onClick={() => setIsActive(!isActive)} data-theme={theme} className={cn(TreeDropDownVariants({ variant, active: isActive }), className)}>
|
|
56
|
+
<button className={cn("outline-none border-none flex-0 leading-0 transition-transform ease-in-out flex justify-center items-center bg-background-system-body-tertiary h-[28px] w-[28px] rounded-full text-[20px] text-content-system-global-primary", { "rotate-180": isActive })}>
|
|
57
|
+
<i className={cn("leading-none ri-arrow-down-s-line ")}></i>
|
|
58
|
+
</button>
|
|
59
|
+
<div className={cn("text-content-system-global-primary typography-body-medium-medium transition-all ease-in-out duration-100 flex-1")}>{treeLabel}</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div className={cn("mt-0 pl-[22px] relative overflow-auto scrollbar-hide transition-all duration-500 ease-in-out", {
|
|
62
|
+
"max-h-[20000px] mt-1": isActive, "max-h-0": !isActive,
|
|
63
|
+
})}>
|
|
64
|
+
<span className="h-full w-[1px] bg-border-system-global-primary absolute left-[21px] top-0 rounded-sm z-10" />
|
|
65
|
+
<div className={cn("h-full flex flex-col gap-1 w-full", childrenContainerClassName)}>{props.children}</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { CSSProperties, HTMLProps, MutableRefObject, createContext, useCallback, useContext, useMemo, useReducer } from 'react'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_HEIGHT = 216
|
|
4
|
+
const DEFAULT_ITEM_HEIGHT = 36
|
|
5
|
+
const DEFAULT_WHEEL_MODE = 'off'
|
|
6
|
+
|
|
7
|
+
interface Option {
|
|
8
|
+
value: string | number
|
|
9
|
+
element: MutableRefObject<HTMLElement | null>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PickerValue {
|
|
13
|
+
[key: string]: string | number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PickerRootProps<TType extends PickerValue> extends Omit<HTMLProps<HTMLDivElement>, 'value' | 'onChange'> {
|
|
17
|
+
value: TType
|
|
18
|
+
onChange: (value: TType, key: string) => void
|
|
19
|
+
height?: number
|
|
20
|
+
itemHeight?: number
|
|
21
|
+
wheelMode?: 'off' | 'natural' | 'normal'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PickerDataContext = createContext<{
|
|
25
|
+
height: number
|
|
26
|
+
itemHeight: number
|
|
27
|
+
wheelMode: 'off' | 'natural' | 'normal'
|
|
28
|
+
value: PickerValue
|
|
29
|
+
optionGroups: { [key: string]: Option[] }
|
|
30
|
+
} | null>(null)
|
|
31
|
+
PickerDataContext.displayName = 'PickerDataContext'
|
|
32
|
+
|
|
33
|
+
export function usePickerData(componentName: string) {
|
|
34
|
+
const context = useContext(PickerDataContext)
|
|
35
|
+
if (context === null) {
|
|
36
|
+
const error = new Error(`<${componentName} /> is missing a parent <Picker /> component.`)
|
|
37
|
+
if (Error.captureStackTrace) {
|
|
38
|
+
Error.captureStackTrace(error, usePickerData)
|
|
39
|
+
}
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
42
|
+
return context
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PickerActionsContext = createContext<{
|
|
46
|
+
registerOption(key: string, option: Option): () => void
|
|
47
|
+
change(key: string, value: string | number): boolean
|
|
48
|
+
} | null>(null)
|
|
49
|
+
PickerActionsContext.displayName = 'PickerActionsContext'
|
|
50
|
+
|
|
51
|
+
export function usePickerActions(componentName: string) {
|
|
52
|
+
const context = useContext(PickerActionsContext)
|
|
53
|
+
if (context === null) {
|
|
54
|
+
const error = new Error(`<${componentName} /> is missing a parent <Picker /> component.`)
|
|
55
|
+
if (Error.captureStackTrace) {
|
|
56
|
+
Error.captureStackTrace(error, usePickerActions)
|
|
57
|
+
}
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
60
|
+
return context
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sortByDomNode<T>(
|
|
64
|
+
nodes: T[],
|
|
65
|
+
resolveKey: (item: T) => HTMLElement | null = (i) => i as unknown as HTMLElement | null
|
|
66
|
+
): T[] {
|
|
67
|
+
return nodes.slice().sort((aItem, zItem) => {
|
|
68
|
+
const a = resolveKey(aItem)
|
|
69
|
+
const z = resolveKey(zItem)
|
|
70
|
+
|
|
71
|
+
if (a === null || z === null) return 0
|
|
72
|
+
|
|
73
|
+
const position = a.compareDocumentPosition(z)
|
|
74
|
+
|
|
75
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
|
76
|
+
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
|
77
|
+
return 0
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pickerReducer(
|
|
82
|
+
optionGroups: { [key: string]: Option[] },
|
|
83
|
+
action: {
|
|
84
|
+
type: 'REGISTER_OPTION' | 'UNREGISTER_OPTION'
|
|
85
|
+
key: string
|
|
86
|
+
option: Option
|
|
87
|
+
}
|
|
88
|
+
) {
|
|
89
|
+
switch (action.type) {
|
|
90
|
+
case 'REGISTER_OPTION': {
|
|
91
|
+
const { key, option } = action
|
|
92
|
+
let nextOptionsForKey = [...(optionGroups[key] || []), option]
|
|
93
|
+
nextOptionsForKey = sortByDomNode(nextOptionsForKey, (o) => o.element.current)
|
|
94
|
+
return {
|
|
95
|
+
...optionGroups,
|
|
96
|
+
[key]: nextOptionsForKey,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
case 'UNREGISTER_OPTION': {
|
|
100
|
+
const { key, option } = action
|
|
101
|
+
return {
|
|
102
|
+
...optionGroups,
|
|
103
|
+
[key]: (optionGroups[key] || []).filter((o) => o !== option),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
default: {
|
|
107
|
+
throw Error(`Unknown action: ${action.type as string}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function PickerRoot<TType extends PickerValue>(props: PickerRootProps<TType> | any) {
|
|
113
|
+
const {
|
|
114
|
+
style,
|
|
115
|
+
children,
|
|
116
|
+
value,
|
|
117
|
+
onChange,
|
|
118
|
+
height = DEFAULT_HEIGHT,
|
|
119
|
+
itemHeight = DEFAULT_ITEM_HEIGHT,
|
|
120
|
+
wheelMode = DEFAULT_WHEEL_MODE,
|
|
121
|
+
selectContainerClassName,
|
|
122
|
+
...restProps
|
|
123
|
+
} = props
|
|
124
|
+
|
|
125
|
+
const highlightStyle = useMemo<CSSProperties>(
|
|
126
|
+
() => ({
|
|
127
|
+
height: itemHeight,
|
|
128
|
+
marginTop: -(itemHeight / 2),
|
|
129
|
+
position: 'absolute',
|
|
130
|
+
top: '50%',
|
|
131
|
+
left: 0,
|
|
132
|
+
width: '100%',
|
|
133
|
+
pointerEvents: 'none',
|
|
134
|
+
}),
|
|
135
|
+
[itemHeight]
|
|
136
|
+
)
|
|
137
|
+
const containerStyle = useMemo<CSSProperties>(
|
|
138
|
+
() => ({
|
|
139
|
+
height: `${height}px`,
|
|
140
|
+
position: 'relative',
|
|
141
|
+
display: 'flex',
|
|
142
|
+
justifyContent: 'center',
|
|
143
|
+
overflow: 'hidden',
|
|
144
|
+
maskImage: 'linear-gradient(to top, transparent, transparent 10%, white 50%, white 60%, transparent 90%, transparent)',
|
|
145
|
+
WebkitMaskImage: 'linear-gradient(to top, transparent, transparent 10%, white 50%, white 60%, transparent 90%, transparent)',
|
|
146
|
+
}),
|
|
147
|
+
[height]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const [optionGroups, dispatch] = useReducer(pickerReducer, {})
|
|
151
|
+
|
|
152
|
+
const pickerData = useMemo(
|
|
153
|
+
() => ({ height, itemHeight, wheelMode, value, optionGroups }),
|
|
154
|
+
[height, itemHeight, value, optionGroups, wheelMode]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const triggerChange = useCallback((key: string, nextValue: string) => {
|
|
158
|
+
if (value[key] === nextValue) return false
|
|
159
|
+
const nextPickerValue = { ...value, [key]: nextValue }
|
|
160
|
+
onChange(nextPickerValue, key)
|
|
161
|
+
return true
|
|
162
|
+
}, [onChange, value])
|
|
163
|
+
const registerOption = useCallback((key: string, option: Option) => {
|
|
164
|
+
dispatch({ type: 'REGISTER_OPTION', key, option })
|
|
165
|
+
return () => dispatch({ type: 'UNREGISTER_OPTION', key, option })
|
|
166
|
+
}, [])
|
|
167
|
+
const pickerActions = useMemo(
|
|
168
|
+
() => ({ registerOption, change: triggerChange }),
|
|
169
|
+
[registerOption, triggerChange]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div
|
|
174
|
+
style={{
|
|
175
|
+
...containerStyle,
|
|
176
|
+
...style,
|
|
177
|
+
}}
|
|
178
|
+
{...restProps}
|
|
179
|
+
>
|
|
180
|
+
<PickerActionsContext.Provider value={pickerActions}>
|
|
181
|
+
<PickerDataContext.Provider value={pickerData}>
|
|
182
|
+
{children}
|
|
183
|
+
</PickerDataContext.Provider>
|
|
184
|
+
</PickerActionsContext.Provider>
|
|
185
|
+
<div
|
|
186
|
+
className={selectContainerClassName}
|
|
187
|
+
style={highlightStyle}
|
|
188
|
+
>
|
|
189
|
+
<div
|
|
190
|
+
style={{
|
|
191
|
+
position: 'absolute',
|
|
192
|
+
top: 0,
|
|
193
|
+
bottom: 'auto',
|
|
194
|
+
left: 0,
|
|
195
|
+
right: 'auto',
|
|
196
|
+
width: '100%',
|
|
197
|
+
height: '1px',
|
|
198
|
+
transform: 'scaleY(0.5)',
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
<div
|
|
202
|
+
style={{
|
|
203
|
+
position: 'absolute',
|
|
204
|
+
top: 'auto',
|
|
205
|
+
bottom: 0,
|
|
206
|
+
left: 0,
|
|
207
|
+
right: 'auto',
|
|
208
|
+
width: '100%',
|
|
209
|
+
height: '1px',
|
|
210
|
+
transform: 'scaleY(0.5)',
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default PickerRoot
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { CSSProperties, HTMLProps, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import { usePickerActions, usePickerData } from "./Picker"
|
|
3
|
+
|
|
4
|
+
interface PickerColumnProps extends HTMLProps<HTMLDivElement> {
|
|
5
|
+
name: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const PickerColumnDataContext = createContext<{
|
|
9
|
+
key: string
|
|
10
|
+
} | null>(null)
|
|
11
|
+
PickerColumnDataContext.displayName = 'PickerColumnDataContext'
|
|
12
|
+
|
|
13
|
+
export function useColumnData(componentName: string) {
|
|
14
|
+
const context = useContext(PickerColumnDataContext)
|
|
15
|
+
if (context === null) {
|
|
16
|
+
const error = new Error(`<${componentName} /> is missing a parent <Picker.Column /> component.`)
|
|
17
|
+
if (Error.captureStackTrace) {
|
|
18
|
+
Error.captureStackTrace(error, useColumnData)
|
|
19
|
+
}
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
return context
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function PickerColumn({
|
|
26
|
+
style,
|
|
27
|
+
children,
|
|
28
|
+
name: key,
|
|
29
|
+
...restProps
|
|
30
|
+
}: PickerColumnProps) {
|
|
31
|
+
const { height, itemHeight, wheelMode, value: groupValue, optionGroups } = usePickerData('Picker.Column')
|
|
32
|
+
|
|
33
|
+
// Caculate the selected index
|
|
34
|
+
const value = useMemo(
|
|
35
|
+
() => groupValue[key],
|
|
36
|
+
[groupValue, key],
|
|
37
|
+
)
|
|
38
|
+
const options = useMemo(
|
|
39
|
+
() => optionGroups[key] || [],
|
|
40
|
+
[key, optionGroups],
|
|
41
|
+
)
|
|
42
|
+
const selectedIndex = useMemo(
|
|
43
|
+
() => {
|
|
44
|
+
let index = options.findIndex((o) => o.value === value)
|
|
45
|
+
if (index < 0) {
|
|
46
|
+
index = 0
|
|
47
|
+
}
|
|
48
|
+
return index
|
|
49
|
+
},
|
|
50
|
+
[options, value],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// Caculate the translate of scroller
|
|
54
|
+
const minTranslate = useMemo(
|
|
55
|
+
() => height / 2 - itemHeight * options.length + itemHeight / 2,
|
|
56
|
+
[height, itemHeight, options],
|
|
57
|
+
)
|
|
58
|
+
const maxTranslate = useMemo(
|
|
59
|
+
() => height / 2 - itemHeight / 2,
|
|
60
|
+
[height, itemHeight],
|
|
61
|
+
)
|
|
62
|
+
const [scrollerTranslate, setScrollerTranslate] = useState<number>(0)
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setScrollerTranslate(height / 2 - itemHeight / 2 - selectedIndex * itemHeight)
|
|
65
|
+
}, [height, itemHeight, selectedIndex])
|
|
66
|
+
|
|
67
|
+
// A handler to trigger the value change
|
|
68
|
+
const pickerActions = usePickerActions('Picker.Column')
|
|
69
|
+
const translateRef = useRef<number>(scrollerTranslate)
|
|
70
|
+
translateRef.current = scrollerTranslate
|
|
71
|
+
const handleScrollerTranslateSettled = useCallback(() => {
|
|
72
|
+
let nextActiveIndex = 0
|
|
73
|
+
const currentTrans = translateRef.current
|
|
74
|
+
if (currentTrans >= maxTranslate) {
|
|
75
|
+
nextActiveIndex = 0
|
|
76
|
+
} else if (currentTrans <= minTranslate) {
|
|
77
|
+
nextActiveIndex = options.length - 1
|
|
78
|
+
} else {
|
|
79
|
+
nextActiveIndex = -Math.round((currentTrans - maxTranslate) / itemHeight)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const changed = pickerActions.change(key, options[nextActiveIndex].value)
|
|
83
|
+
if (!changed) {
|
|
84
|
+
setScrollerTranslate(height / 2 - itemHeight / 2 - nextActiveIndex * itemHeight)
|
|
85
|
+
}
|
|
86
|
+
}, [pickerActions, height, itemHeight, key, maxTranslate, minTranslate, options])
|
|
87
|
+
|
|
88
|
+
// Handle touch events
|
|
89
|
+
const [startScrollerTranslate, setStartScrollerTranslate] = useState<number>(0)
|
|
90
|
+
const [isMoving, setIsMoving] = useState<boolean>(false)
|
|
91
|
+
const [startTouchY, setStartTouchY] = useState<number>(0)
|
|
92
|
+
|
|
93
|
+
const updateScrollerWhileMoving = useCallback((nextScrollerTranslate: number) => {
|
|
94
|
+
if (nextScrollerTranslate < minTranslate) {
|
|
95
|
+
nextScrollerTranslate = minTranslate - Math.pow(minTranslate - nextScrollerTranslate, 0.8)
|
|
96
|
+
} else if (nextScrollerTranslate > maxTranslate) {
|
|
97
|
+
nextScrollerTranslate = maxTranslate + Math.pow(nextScrollerTranslate - maxTranslate, 0.8)
|
|
98
|
+
}
|
|
99
|
+
setScrollerTranslate(nextScrollerTranslate)
|
|
100
|
+
}, [maxTranslate, minTranslate])
|
|
101
|
+
|
|
102
|
+
const handleTouchStart = useCallback((event: React.TouchEvent) => {
|
|
103
|
+
setStartTouchY(event.targetTouches[0].pageY)
|
|
104
|
+
setStartScrollerTranslate(scrollerTranslate)
|
|
105
|
+
}, [scrollerTranslate])
|
|
106
|
+
|
|
107
|
+
const handleTouchMove = useCallback((event: TouchEvent) => {
|
|
108
|
+
if (event.cancelable) {
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!isMoving) {
|
|
113
|
+
setIsMoving(true)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const nextScrollerTranslate = startScrollerTranslate + event.targetTouches[0].pageY - startTouchY
|
|
117
|
+
updateScrollerWhileMoving(nextScrollerTranslate)
|
|
118
|
+
}, [isMoving, startScrollerTranslate, startTouchY, updateScrollerWhileMoving])
|
|
119
|
+
|
|
120
|
+
const handleTouchEnd = useCallback(() => {
|
|
121
|
+
if (!isMoving) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
setIsMoving(false)
|
|
125
|
+
setStartTouchY(0)
|
|
126
|
+
setStartScrollerTranslate(0)
|
|
127
|
+
|
|
128
|
+
handleScrollerTranslateSettled()
|
|
129
|
+
}, [handleScrollerTranslateSettled, isMoving])
|
|
130
|
+
|
|
131
|
+
const handleTouchCancel = useCallback(() => {
|
|
132
|
+
if (!isMoving) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
setIsMoving(false)
|
|
136
|
+
setStartTouchY(0)
|
|
137
|
+
setScrollerTranslate(startScrollerTranslate)
|
|
138
|
+
setStartScrollerTranslate(0)
|
|
139
|
+
}, [isMoving, startScrollerTranslate])
|
|
140
|
+
|
|
141
|
+
// Handle wheel events
|
|
142
|
+
const wheelingTimer = useRef<number | null>(null)
|
|
143
|
+
|
|
144
|
+
const handleWheeling = useCallback((event: WheelEvent) => {
|
|
145
|
+
if (event.deltaY === 0) {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
let delta = event.deltaY * 0.1
|
|
149
|
+
if (Math.abs(delta) < itemHeight) {
|
|
150
|
+
delta = itemHeight * Math.sign(delta)
|
|
151
|
+
}
|
|
152
|
+
if (wheelMode === 'normal') {
|
|
153
|
+
delta = -delta
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
const nextScrollerTranslate = scrollerTranslate + delta
|
|
158
|
+
updateScrollerWhileMoving(nextScrollerTranslate)
|
|
159
|
+
}, [itemHeight, scrollerTranslate, updateScrollerWhileMoving, wheelMode])
|
|
160
|
+
|
|
161
|
+
const handleWheelEnd = useCallback(() => {
|
|
162
|
+
handleScrollerTranslateSettled()
|
|
163
|
+
}, [handleScrollerTranslateSettled])
|
|
164
|
+
|
|
165
|
+
const handleWheel = useCallback((event: WheelEvent) => {
|
|
166
|
+
if (wheelMode === 'off') {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (event.cancelable) {
|
|
171
|
+
event.preventDefault()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
handleWheeling(event)
|
|
175
|
+
|
|
176
|
+
if (wheelingTimer.current) {
|
|
177
|
+
clearTimeout(wheelingTimer.current)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
wheelingTimer.current = setTimeout(() => {
|
|
181
|
+
handleWheelEnd()
|
|
182
|
+
}, 200) as unknown as number
|
|
183
|
+
}, [handleWheelEnd, handleWheeling, wheelingTimer, wheelMode])
|
|
184
|
+
|
|
185
|
+
// 'touchmove' and 'wheel' should not be passive
|
|
186
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const container = containerRef.current
|
|
189
|
+
if (container) {
|
|
190
|
+
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
191
|
+
container.addEventListener('wheel', handleWheel, { passive: false })
|
|
192
|
+
}
|
|
193
|
+
return () => {
|
|
194
|
+
if (container) {
|
|
195
|
+
container.removeEventListener('touchmove', handleTouchMove)
|
|
196
|
+
container.removeEventListener('wheel', handleWheel)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}, [handleTouchMove, handleWheel])
|
|
200
|
+
|
|
201
|
+
const columnStyle = useMemo<CSSProperties>(
|
|
202
|
+
() => ({
|
|
203
|
+
flex: '1 1 0%',
|
|
204
|
+
maxHeight: '100%',
|
|
205
|
+
transitionProperty: 'transform',
|
|
206
|
+
transitionTimingFunction: 'cubic-bezier(0, 0, 0.2, 1)',
|
|
207
|
+
transitionDuration: isMoving ? '0ms' : '300ms',
|
|
208
|
+
transform: `translate3d(0, ${scrollerTranslate}px, 0)`,
|
|
209
|
+
}),
|
|
210
|
+
[scrollerTranslate, isMoving],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const columnData = useMemo(
|
|
214
|
+
() => ({ key }),
|
|
215
|
+
[key],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div
|
|
220
|
+
style={{
|
|
221
|
+
...columnStyle,
|
|
222
|
+
...style,
|
|
223
|
+
}}
|
|
224
|
+
ref={containerRef}
|
|
225
|
+
onTouchStart={handleTouchStart}
|
|
226
|
+
onTouchEnd={handleTouchEnd}
|
|
227
|
+
onTouchCancel={handleTouchCancel}
|
|
228
|
+
{...restProps}
|
|
229
|
+
>
|
|
230
|
+
<PickerColumnDataContext.Provider value={columnData}>
|
|
231
|
+
{children}
|
|
232
|
+
</PickerColumnDataContext.Provider>
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
export default PickerColumn
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { HTMLProps, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { usePickerActions, usePickerData } from './Picker.tsx'
|
|
3
|
+
import { useColumnData } from './PickerColumn.tsx'
|
|
4
|
+
|
|
5
|
+
interface PickerItemRenderProps {
|
|
6
|
+
selected: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PickerItemProps extends Omit<HTMLProps<HTMLDivElement>, 'value' | 'children'> {
|
|
10
|
+
children: ReactNode | ((renderProps: PickerItemRenderProps) => ReactNode)
|
|
11
|
+
value: string | number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line
|
|
15
|
+
function isFunction(functionToCheck: any): functionToCheck is Function {
|
|
16
|
+
return typeof functionToCheck === 'function'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function PickerItem({
|
|
20
|
+
style,
|
|
21
|
+
children,
|
|
22
|
+
value,
|
|
23
|
+
...restProps
|
|
24
|
+
}: PickerItemProps) {
|
|
25
|
+
const optionRef = useRef<HTMLDivElement | null>(null)
|
|
26
|
+
const { itemHeight, value: pickerValue } = usePickerData('Picker.Item')
|
|
27
|
+
const pickerActions = usePickerActions('Picker.Item')
|
|
28
|
+
const { key } = useColumnData('Picker.Item')
|
|
29
|
+
|
|
30
|
+
useEffect(
|
|
31
|
+
() => pickerActions.registerOption(key, { value, element: optionRef }),
|
|
32
|
+
[key, pickerActions, value],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const itemStyle = useMemo(
|
|
36
|
+
() => ({
|
|
37
|
+
height: `${itemHeight}px`,
|
|
38
|
+
display: 'flex',
|
|
39
|
+
justifyContent: 'center',
|
|
40
|
+
alignItems: 'center',
|
|
41
|
+
}),
|
|
42
|
+
[itemHeight],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const handleClick = useCallback(() => {
|
|
46
|
+
pickerActions.change(key, value)
|
|
47
|
+
}, [pickerActions, key, value])
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
...itemStyle,
|
|
53
|
+
...style,
|
|
54
|
+
}}
|
|
55
|
+
ref={optionRef}
|
|
56
|
+
onClick={handleClick}
|
|
57
|
+
{...restProps}
|
|
58
|
+
>
|
|
59
|
+
{isFunction(children) ? children({ selected: pickerValue[key] === value }) : children}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default PickerItem
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import Picker, { PickerValue, PickerRootProps as PickerProps } from './components/Picker.tsx'
|
|
2
|
+
import Column from './components/PickerColumn.tsx'
|
|
3
|
+
import Item from './components/PickerItem.tsx'
|
|
4
|
+
|
|
5
|
+
export type { PickerProps, PickerValue }
|
|
6
|
+
|
|
7
|
+
export default Object.assign(Picker, {
|
|
8
|
+
Column,
|
|
9
|
+
Item,
|
|
10
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
export function useActiveTreeItem(itemIds: string[]) {
|
|
5
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!itemIds || itemIds.length === 0) {
|
|
9
|
+
console.warn("No itemIds provided to useActiveTreeItem.");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const observer = new IntersectionObserver(
|
|
14
|
+
(entries) => {
|
|
15
|
+
let mostVisibleEntry: any = null;
|
|
16
|
+
|
|
17
|
+
entries.forEach((entry) => {
|
|
18
|
+
if (entry.isIntersecting) {
|
|
19
|
+
// Track the most visible entry (highest intersection ratio)
|
|
20
|
+
if (
|
|
21
|
+
!mostVisibleEntry ||
|
|
22
|
+
entry.intersectionRatio > mostVisibleEntry.intersectionRatio
|
|
23
|
+
) {
|
|
24
|
+
mostVisibleEntry = entry;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (mostVisibleEntry) {
|
|
30
|
+
setActiveId(mostVisibleEntry.target.id);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
rootMargin: '-10% 0% -5% 0%', // Adjust based on your layout
|
|
35
|
+
threshold: [0, 0.25, 0.5, 0.75, 1], // Multiple thresholds for better accuracy
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Observe all elements
|
|
40
|
+
itemIds.forEach((id) => {
|
|
41
|
+
const element = document.getElementById(id);
|
|
42
|
+
if (element) {
|
|
43
|
+
observer.observe(element);
|
|
44
|
+
} else {
|
|
45
|
+
console.warn(`Element with id "${id}" not found.`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Cleanup observer
|
|
50
|
+
return () => {
|
|
51
|
+
itemIds.forEach((id) => {
|
|
52
|
+
const element = document.getElementById(id);
|
|
53
|
+
if (element) {
|
|
54
|
+
observer.unobserve(element);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
}, [itemIds]);
|
|
59
|
+
|
|
60
|
+
return { activeId };
|
|
61
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export function useClickOutside<T extends HTMLElement>(callback: () => void) {
|
|
4
|
+
const ref = useRef<T>(null);
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
function handleClickOutside(event: MouseEvent) {
|
|
8
|
+
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
9
|
+
callback();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
document.addEventListener("click", handleClickOutside);
|
|
14
|
+
return () => document.removeEventListener("click", handleClickOutside);
|
|
15
|
+
}, [callback]);
|
|
16
|
+
|
|
17
|
+
return ref;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|