jy-headless 0.3.0 → 0.3.7
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/Input/NumberInput.d.ts +3 -0
- package/Input/NumberInput.type.d.ts +31 -0
- package/Input/TextInput.d.ts +3 -0
- package/Input/TextInput.type.d.ts +95 -0
- package/Input/index.d.ts +2 -0
- package/Popover/Popover.d.ts +2 -2
- package/Popover/Popover.js +15 -62
- package/Popover/Popover.type.d.ts +6 -3
- package/Select/Select.d.ts +51 -0
- package/Select/Select.js +176 -0
- package/Select/Select.type.d.ts +52 -0
- package/Select/index.d.ts +1 -0
- package/Tooltip/Tooltip.d.ts +10 -0
- package/Tooltip/Tooltip.js +36 -0
- package/Tooltip/Tooltip.type.d.ts +20 -0
- package/Tooltip/index.d.ts +2 -0
- package/cjs/Input/NumberInput.d.ts +3 -0
- package/cjs/Input/NumberInput.type.d.ts +31 -0
- package/cjs/Input/TextInput.d.ts +3 -0
- package/cjs/Input/TextInput.type.d.ts +95 -0
- package/cjs/Input/index.d.ts +2 -0
- package/cjs/Popover/Popover.d.ts +2 -2
- package/cjs/Popover/Popover.js +13 -60
- package/cjs/Popover/Popover.type.d.ts +6 -3
- package/cjs/Select/Select.d.ts +51 -0
- package/cjs/Select/Select.js +178 -0
- package/cjs/Select/Select.type.d.ts +52 -0
- package/cjs/Select/index.d.ts +1 -0
- package/cjs/Tooltip/Tooltip.d.ts +10 -0
- package/cjs/Tooltip/Tooltip.js +38 -0
- package/cjs/Tooltip/Tooltip.type.d.ts +20 -0
- package/cjs/Tooltip/index.d.ts +2 -0
- package/cjs/hooks/index.d.ts +3 -0
- package/cjs/hooks/useDebounce.d.ts +1 -0
- package/cjs/hooks/useDebounce.js +24 -0
- package/cjs/hooks/usePortal.d.ts +23 -0
- package/cjs/hooks/usePortal.js +80 -0
- package/cjs/hooks/useThrottle.d.ts +1 -0
- package/cjs/hooks/useThrottle.js +34 -0
- package/cjs/index.d.ts +4 -1
- package/cjs/index.js +11 -2
- package/hooks/index.d.ts +3 -0
- package/hooks/useDebounce.d.ts +1 -0
- package/hooks/useDebounce.js +22 -0
- package/hooks/usePortal.d.ts +23 -0
- package/hooks/usePortal.js +78 -0
- package/hooks/useThrottle.d.ts +1 -0
- package/hooks/useThrottle.js +32 -0
- package/index.d.ts +4 -1
- package/index.js +7 -1
- package/package.json +31 -6
- package/version.txt +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { TextInputProps } from './TextInput.type';
|
|
2
|
+
/**
|
|
3
|
+
* 숫자 전용 입력 컴포넌트 props
|
|
4
|
+
*
|
|
5
|
+
* TextInput을 기반으로 하며,
|
|
6
|
+
* 숫자 입력에 특화된 기능을 제공합니다.
|
|
7
|
+
*/
|
|
8
|
+
export interface NumberInputProps extends TextInputProps {
|
|
9
|
+
/**
|
|
10
|
+
* 최대 허용 값
|
|
11
|
+
*
|
|
12
|
+
* 초과 시:
|
|
13
|
+
* - 입력 제한
|
|
14
|
+
* - 또는 validation 에러 처리 가능
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* max={100}
|
|
18
|
+
*/
|
|
19
|
+
max?: number;
|
|
20
|
+
/**
|
|
21
|
+
* 천 단위 구분자 사용 여부
|
|
22
|
+
*
|
|
23
|
+
* true일 경우:
|
|
24
|
+
* 10000 → 10,000
|
|
25
|
+
*
|
|
26
|
+
* 내부 값은 parse 단계에서 콤마 제거 권장
|
|
27
|
+
*
|
|
28
|
+
* @default false
|
|
29
|
+
*/
|
|
30
|
+
useThousandsSeparator?: boolean;
|
|
31
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { TextInputProps } from './TextInput.type';
|
|
2
|
+
declare const TextInput: ({ maxLength, onChange, pattern, onValidate, validator, onCompositionStart, onCompositionEnd, disallowPattern, trimWhitespace, debounceMs, throttleMs, onDebouncedChange, onThrottledChange, onBlur, ...props }: TextInputProps) => import("react/jsx-runtime").JSX.Element;
|
|
3
|
+
export default TextInput;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ChangeEvent, CompositionEvent, HTMLProps } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* 고급 기능을 제공하는 TextInput 컴포넌트 props
|
|
4
|
+
*
|
|
5
|
+
* 기능:
|
|
6
|
+
* - validation
|
|
7
|
+
* - formatting/parsing
|
|
8
|
+
* - debounce/throttle
|
|
9
|
+
* - IME(한글 입력) 대응
|
|
10
|
+
* - 공백 제어
|
|
11
|
+
*/
|
|
12
|
+
export interface TextInputProps extends HTMLProps<HTMLInputElement> {
|
|
13
|
+
/**
|
|
14
|
+
* 입력값 검증 함수
|
|
15
|
+
*
|
|
16
|
+
* return:
|
|
17
|
+
* - true → 유효
|
|
18
|
+
* - false → 유효하지 않음
|
|
19
|
+
* - string → 에러 메시지
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* validator={(v) => v.length > 3 || "최소 3자 이상"}
|
|
23
|
+
*/
|
|
24
|
+
validator?: (value: string) => boolean | string;
|
|
25
|
+
/**
|
|
26
|
+
* 검증 결과 콜백
|
|
27
|
+
*/
|
|
28
|
+
onValidate?: (isValid: boolean, error?: string) => void;
|
|
29
|
+
/**
|
|
30
|
+
* 표시용 값 포맷팅
|
|
31
|
+
*
|
|
32
|
+
* ex) 숫자 천단위 콤마
|
|
33
|
+
*/
|
|
34
|
+
format?: (value: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* 실제 값으로 변환
|
|
37
|
+
*
|
|
38
|
+
* ex) 콤마 제거
|
|
39
|
+
*/
|
|
40
|
+
parse?: (value: string) => string;
|
|
41
|
+
/**
|
|
42
|
+
* 입력을 막을 패턴
|
|
43
|
+
*
|
|
44
|
+
* @example 숫자만 허용
|
|
45
|
+
* disallowPattern={/[^0-9]/g}
|
|
46
|
+
*/
|
|
47
|
+
disallowPattern?: RegExp;
|
|
48
|
+
/**
|
|
49
|
+
* 공백 제거 방식
|
|
50
|
+
*
|
|
51
|
+
* - true → 전체 trim
|
|
52
|
+
* - 'leading' → 앞 공백만
|
|
53
|
+
* - 'trailing' → 뒤 공백만
|
|
54
|
+
* - 'both' → 양쪽
|
|
55
|
+
*/
|
|
56
|
+
trimWhitespace?: boolean | 'leading' | 'trailing' | 'both';
|
|
57
|
+
/**
|
|
58
|
+
* IME 입력 시작 이벤트
|
|
59
|
+
*
|
|
60
|
+
* 한글/중국어 입력 시 중요
|
|
61
|
+
*/
|
|
62
|
+
onCompositionStart?: (e: CompositionEvent<HTMLInputElement>) => void;
|
|
63
|
+
/**
|
|
64
|
+
* IME 입력 종료 이벤트
|
|
65
|
+
*/
|
|
66
|
+
onCompositionEnd?: (e: CompositionEvent<HTMLInputElement>) => void;
|
|
67
|
+
/**
|
|
68
|
+
* IME 입력 중 이벤트
|
|
69
|
+
*/
|
|
70
|
+
onCompositionUpdate?: (e: CompositionEvent<HTMLInputElement>) => void;
|
|
71
|
+
/**
|
|
72
|
+
* debounce 지연(ms)
|
|
73
|
+
*
|
|
74
|
+
* @example 300
|
|
75
|
+
*/
|
|
76
|
+
debounceMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* throttle 지연(ms)
|
|
79
|
+
*/
|
|
80
|
+
throttleMs?: number;
|
|
81
|
+
/**
|
|
82
|
+
* debounce 후 값 변경 콜백
|
|
83
|
+
*/
|
|
84
|
+
onDebouncedChange?: (value: string) => void;
|
|
85
|
+
/**
|
|
86
|
+
* throttle 후 값 변경 콜백
|
|
87
|
+
*/
|
|
88
|
+
onThrottledChange?: (value: string) => void;
|
|
89
|
+
/**
|
|
90
|
+
* 기본 onChange override
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ 일반 HTML onChange와 동일
|
|
93
|
+
*/
|
|
94
|
+
onChange?: (value: ChangeEvent<HTMLInputElement>) => void;
|
|
95
|
+
}
|
package/Input/index.d.ts
ADDED
package/Popover/Popover.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare const Popover: ({
|
|
1
|
+
import { PopoverProps } from './Popover.type';
|
|
2
|
+
export declare const Popover: ({ direction, popover, children, key, gap, autoFlip }: PopoverProps) => import("react/jsx-runtime").JSX.Element;
|
package/Popover/Popover.js
CHANGED
|
@@ -1,75 +1,28 @@
|
|
|
1
|
-
import { jsxs
|
|
2
|
-
import { useState, useRef
|
|
3
|
-
import
|
|
1
|
+
import { jsxs } from 'react/jsx-runtime';
|
|
2
|
+
import { useState, useRef } from 'react';
|
|
3
|
+
import usePortal from '../hooks/usePortal.js';
|
|
4
4
|
|
|
5
|
-
const Popover = ({
|
|
5
|
+
const Popover = ({ direction = 'top', popover, children, key, gap = 0, autoFlip = true }) => {
|
|
6
6
|
const [visible, setVisible] = useState(false);
|
|
7
|
-
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
8
|
-
const rootDom = domNode ?? document.getElementById(rootId ?? 'root');
|
|
9
7
|
const targetRef = useRef(null);
|
|
10
8
|
const popoverRef = useRef(null);
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
top = targetRect.top - popoverRect.height;
|
|
22
|
-
left = targetRect.left + (targetRect.width - popoverRect.width) / 2;
|
|
23
|
-
break;
|
|
24
|
-
case 'top-right':
|
|
25
|
-
top = targetRect.top - popoverRect.height;
|
|
26
|
-
left = targetRect.right - popoverRect.width;
|
|
27
|
-
break;
|
|
28
|
-
case 'left':
|
|
29
|
-
top = targetRect.top + (targetRect.height - popoverRect.height) / 2;
|
|
30
|
-
left = targetRect.left - popoverRect.width;
|
|
31
|
-
break;
|
|
32
|
-
case 'right':
|
|
33
|
-
top = targetRect.top + (targetRect.height - popoverRect.height) / 2;
|
|
34
|
-
left = targetRect.right;
|
|
35
|
-
break;
|
|
36
|
-
case 'bottom-left':
|
|
37
|
-
top = targetRect.bottom;
|
|
38
|
-
left = targetRect.left;
|
|
39
|
-
break;
|
|
40
|
-
case 'bottom-center':
|
|
41
|
-
case 'bottom':
|
|
42
|
-
top = targetRect.bottom;
|
|
43
|
-
left = targetRect.left + (targetRect.width - popoverRect.width) / 2;
|
|
44
|
-
break;
|
|
45
|
-
case 'bottom-right':
|
|
46
|
-
top = targetRect.bottom;
|
|
47
|
-
left = targetRect.right - popoverRect.width;
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
return { top, left };
|
|
51
|
-
}, [direction]);
|
|
9
|
+
const { portal, rootDom } = usePortal({
|
|
10
|
+
content: popover,
|
|
11
|
+
key,
|
|
12
|
+
visible,
|
|
13
|
+
targetRef,
|
|
14
|
+
popoverRef,
|
|
15
|
+
direction,
|
|
16
|
+
gap,
|
|
17
|
+
autoFlip,
|
|
18
|
+
});
|
|
52
19
|
const handleMouseEnter = () => {
|
|
53
20
|
setVisible(true);
|
|
54
21
|
};
|
|
55
22
|
const handleMouseLeave = () => {
|
|
56
23
|
setVisible(false);
|
|
57
24
|
};
|
|
58
|
-
|
|
59
|
-
if (visible && targetRef.current && popoverRef.current) {
|
|
60
|
-
const targetRect = targetRef.current.getBoundingClientRect();
|
|
61
|
-
const popoverRect = popoverRef.current.getBoundingClientRect();
|
|
62
|
-
const { top, left } = getPopoverPosition(targetRect, popoverRect);
|
|
63
|
-
setPosition({ top: top + window.scrollY, left: left + window.scrollX });
|
|
64
|
-
}
|
|
65
|
-
}, [visible, popover, getPopoverPosition]);
|
|
66
|
-
return (jsxs("span", { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, ref: targetRef, children: [children, rootDom &&
|
|
67
|
-
visible &&
|
|
68
|
-
createPortal(jsx("span", { ref: popoverRef, style: {
|
|
69
|
-
position: 'absolute',
|
|
70
|
-
top: `${position.top}px`,
|
|
71
|
-
left: `${position.left}px`,
|
|
72
|
-
}, children: popover }), rootDom, key)] }));
|
|
25
|
+
return (jsxs("span", { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, ref: targetRef, children: [children, rootDom && visible && portal] }));
|
|
73
26
|
};
|
|
74
27
|
|
|
75
28
|
export { Popover };
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
|
-
|
|
2
|
+
import { Direction } from '../hooks';
|
|
3
|
+
export interface PopoverProps {
|
|
3
4
|
children: ReactNode;
|
|
4
5
|
popover: ReactNode;
|
|
5
|
-
direction:
|
|
6
|
-
|
|
6
|
+
direction: Direction;
|
|
7
|
+
targetId?: string;
|
|
7
8
|
domNode?: Element;
|
|
8
9
|
key?: string;
|
|
10
|
+
gap?: number;
|
|
11
|
+
autoFlip?: boolean;
|
|
9
12
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Dispatch, SetStateAction } from 'react';
|
|
2
|
+
import { SelectOptionProps, SelectOptionsProps, SelectProps, SelectTriggerProps } from './Select.type';
|
|
3
|
+
/**
|
|
4
|
+
* Select 내부 상태 공유용 Context
|
|
5
|
+
*/
|
|
6
|
+
type SelectContextValue = {
|
|
7
|
+
/** 드롭다운 열림 여부 */
|
|
8
|
+
open: boolean;
|
|
9
|
+
/** 드롭다운 열기/닫기 */
|
|
10
|
+
setOpen: (v: boolean) => void;
|
|
11
|
+
/** 현재 선택된 값들 */
|
|
12
|
+
value: string[];
|
|
13
|
+
/** 값 토글 */
|
|
14
|
+
toggleValue: (v: string) => void;
|
|
15
|
+
/** 다중 선택 여부 */
|
|
16
|
+
multiple: boolean;
|
|
17
|
+
/** Trigger DOM ref */
|
|
18
|
+
triggerRef: React.RefObject<HTMLDivElement | null>;
|
|
19
|
+
/** Option DOM refs 목록 */
|
|
20
|
+
optionRefs: React.MutableRefObject<HTMLDivElement[]>;
|
|
21
|
+
/** 현재 포커스된 옵션 index */
|
|
22
|
+
focusedIndex: number | null;
|
|
23
|
+
/** 포커스 index 설정 */
|
|
24
|
+
setFocusedIndex: Dispatch<SetStateAction<number>>;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Select Context 접근 훅
|
|
28
|
+
*
|
|
29
|
+
* @throws Select 외부에서 사용할 경우 에러
|
|
30
|
+
*/
|
|
31
|
+
export declare const useSelectContext: () => SelectContextValue;
|
|
32
|
+
/**
|
|
33
|
+
* Compound Select 컴포넌트
|
|
34
|
+
*
|
|
35
|
+
* 사용 예시:
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* <Select value={value} onChange={setValue}>
|
|
39
|
+
* <Select.Trigger>열기</Select.Trigger>
|
|
40
|
+
* <Select.Options>
|
|
41
|
+
* <Select.Option value="a">A</Select.Option>
|
|
42
|
+
* <Select.Option value="b">B</Select.Option>
|
|
43
|
+
* </Select.Options>
|
|
44
|
+
* </Select>
|
|
45
|
+
*/
|
|
46
|
+
declare const Select: (({ value, onChange, multiple, children }: SelectProps) => import("react/jsx-runtime").JSX.Element) & {
|
|
47
|
+
Trigger: (props: SelectTriggerProps) => import("react/jsx-runtime").JSX.Element;
|
|
48
|
+
Options: ({ children, ...props }: SelectOptionsProps) => import("react").ReactPortal | null;
|
|
49
|
+
Option: ({ value, disabled, children, ...props }: SelectOptionProps) => import("react/jsx-runtime").JSX.Element;
|
|
50
|
+
};
|
|
51
|
+
export default Select;
|
package/Select/Select.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { createContext, useContext, useState, useRef, useEffect, useMemo } from 'react';
|
|
3
|
+
import usePortal from '../hooks/usePortal.js';
|
|
4
|
+
|
|
5
|
+
const SelectContext = createContext(null);
|
|
6
|
+
/**
|
|
7
|
+
* Select Context 접근 훅
|
|
8
|
+
*
|
|
9
|
+
* @throws Select 외부에서 사용할 경우 에러
|
|
10
|
+
*/
|
|
11
|
+
const useSelectContext = () => {
|
|
12
|
+
const ctx = useContext(SelectContext);
|
|
13
|
+
if (!ctx)
|
|
14
|
+
throw new Error('Select components must be used within <Select>');
|
|
15
|
+
return ctx;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Select 루트 컨테이너
|
|
19
|
+
*
|
|
20
|
+
* - 상태 관리 담당
|
|
21
|
+
* - Context Provider 역할
|
|
22
|
+
*/
|
|
23
|
+
const SelectContainer = ({ value, onChange, multiple = false, children }) => {
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const triggerRef = useRef(null);
|
|
26
|
+
const optionRefs = useRef([]);
|
|
27
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
28
|
+
const toggleValue = (v) => {
|
|
29
|
+
if (!multiple) {
|
|
30
|
+
onChange([v]);
|
|
31
|
+
setOpen(false);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
onChange(value.includes(v)
|
|
35
|
+
? value.filter(i => i !== v)
|
|
36
|
+
: [...value, v]);
|
|
37
|
+
};
|
|
38
|
+
return (jsx(SelectContext.Provider, { value: {
|
|
39
|
+
open,
|
|
40
|
+
setOpen,
|
|
41
|
+
value,
|
|
42
|
+
toggleValue,
|
|
43
|
+
multiple,
|
|
44
|
+
triggerRef,
|
|
45
|
+
optionRefs,
|
|
46
|
+
focusedIndex,
|
|
47
|
+
setFocusedIndex,
|
|
48
|
+
}, children: children }));
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Select 트리거 버튼
|
|
52
|
+
*
|
|
53
|
+
* - 클릭 시 Options 열림
|
|
54
|
+
* - 최초 포커스를 첫 옵션으로 이동
|
|
55
|
+
*/
|
|
56
|
+
const Trigger = (props) => {
|
|
57
|
+
const { open, setOpen, triggerRef, setFocusedIndex } = useSelectContext();
|
|
58
|
+
return (jsx("div", { ref: triggerRef, role: 'button', "aria-expanded": open, onClick: (e) => {
|
|
59
|
+
setOpen(!open);
|
|
60
|
+
setFocusedIndex(0);
|
|
61
|
+
props.onClick?.(e);
|
|
62
|
+
}, ...props }));
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Options 드롭다운 영역
|
|
66
|
+
*
|
|
67
|
+
* 기능:
|
|
68
|
+
* - 외부 클릭 시 닫힘
|
|
69
|
+
* - ESC 키 닫기
|
|
70
|
+
* - ↑ ↓ 키 포커스 이동
|
|
71
|
+
* - portal 렌더링
|
|
72
|
+
*/
|
|
73
|
+
const Options = ({ children, ...props }) => {
|
|
74
|
+
const { open, triggerRef, setOpen, setFocusedIndex, optionRefs } = useSelectContext();
|
|
75
|
+
const popoverRef = useRef(null);
|
|
76
|
+
const triggerWidth = triggerRef.current?.getBoundingClientRect().width;
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!open)
|
|
79
|
+
return;
|
|
80
|
+
const handleOutsideClick = (e) => {
|
|
81
|
+
const target = e.target;
|
|
82
|
+
if (triggerRef.current?.contains(target) ||
|
|
83
|
+
popoverRef.current?.contains(target)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setOpen(false);
|
|
87
|
+
};
|
|
88
|
+
document.addEventListener('mousedown', handleOutsideClick);
|
|
89
|
+
return () => {
|
|
90
|
+
document.removeEventListener('mousedown', handleOutsideClick);
|
|
91
|
+
};
|
|
92
|
+
}, [open, setOpen, triggerRef]);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open)
|
|
95
|
+
return;
|
|
96
|
+
const handleKeyDown = (e) => {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
if (e.key === 'Escape') {
|
|
99
|
+
setOpen(false);
|
|
100
|
+
setFocusedIndex(-1);
|
|
101
|
+
}
|
|
102
|
+
if (e.key === 'ArrowUp') {
|
|
103
|
+
setFocusedIndex(prev => prev - 1 < 0 ? optionRefs.current.length - 1 : prev - 1);
|
|
104
|
+
}
|
|
105
|
+
if (e.key === 'ArrowDown') {
|
|
106
|
+
setFocusedIndex(prev => prev + 1 >= optionRefs.current.length ? 0 : prev + 1);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
110
|
+
return () => {
|
|
111
|
+
optionRefs.current = [];
|
|
112
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
113
|
+
};
|
|
114
|
+
}, [open]);
|
|
115
|
+
const { portal } = usePortal({
|
|
116
|
+
visible: open,
|
|
117
|
+
targetRef: triggerRef,
|
|
118
|
+
popoverRef,
|
|
119
|
+
direction: 'bottom',
|
|
120
|
+
gap: 4,
|
|
121
|
+
content: (jsx("div", { ref: popoverRef, style: { width: triggerWidth }, ...props, children: children })),
|
|
122
|
+
});
|
|
123
|
+
return open ? portal : null;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* 개별 선택 옵션
|
|
127
|
+
*
|
|
128
|
+
* 기능:
|
|
129
|
+
* - 선택 상태 표시
|
|
130
|
+
* - 포커스 관리
|
|
131
|
+
* - disabled 지원
|
|
132
|
+
*/
|
|
133
|
+
const Option = ({ value, disabled, children, ...props }) => {
|
|
134
|
+
const { value: selected, toggleValue, optionRefs, focusedIndex } = useSelectContext();
|
|
135
|
+
const isSelected = selected.includes(value);
|
|
136
|
+
const [index, setIndex] = useState(null);
|
|
137
|
+
const isFocused = useMemo(() => focusedIndex === index, [focusedIndex, index]);
|
|
138
|
+
const ref = useRef(null);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (ref.current && index === null) {
|
|
141
|
+
setIndex(optionRefs.current.length);
|
|
142
|
+
optionRefs.current[optionRefs.current.length] = ref.current;
|
|
143
|
+
}
|
|
144
|
+
}, []);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
ref.current?.setAttribute('data-focused', String(focusedIndex === index));
|
|
147
|
+
if (focusedIndex === index) {
|
|
148
|
+
ref.current?.focus();
|
|
149
|
+
}
|
|
150
|
+
}, [focusedIndex, index]);
|
|
151
|
+
return (jsx("div", { ref: ref, role: 'option', "aria-selected": isSelected, "aria-disabled": disabled, "data-focused": isFocused, onClick: () => {
|
|
152
|
+
if (!disabled)
|
|
153
|
+
toggleValue(value);
|
|
154
|
+
}, ...props, children: children }));
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Compound Select 컴포넌트
|
|
158
|
+
*
|
|
159
|
+
* 사용 예시:
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* <Select value={value} onChange={setValue}>
|
|
163
|
+
* <Select.Trigger>열기</Select.Trigger>
|
|
164
|
+
* <Select.Options>
|
|
165
|
+
* <Select.Option value="a">A</Select.Option>
|
|
166
|
+
* <Select.Option value="b">B</Select.Option>
|
|
167
|
+
* </Select.Options>
|
|
168
|
+
* </Select>
|
|
169
|
+
*/
|
|
170
|
+
Object.assign(SelectContainer, {
|
|
171
|
+
Trigger,
|
|
172
|
+
Options,
|
|
173
|
+
Option,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
export { useSelectContext };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { HTMLProps, ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Select 루트 컴포넌트 props
|
|
4
|
+
*/
|
|
5
|
+
export interface SelectProps {
|
|
6
|
+
/**
|
|
7
|
+
* 선택된 값 목록
|
|
8
|
+
*
|
|
9
|
+
* - single 모드에서도 배열로 관리
|
|
10
|
+
*/
|
|
11
|
+
value: string[];
|
|
12
|
+
/**
|
|
13
|
+
* 값 변경 시 호출
|
|
14
|
+
*/
|
|
15
|
+
onChange: (v: string[]) => void;
|
|
16
|
+
/**
|
|
17
|
+
* 다중 선택 여부
|
|
18
|
+
* @default false
|
|
19
|
+
*/
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Select.Trigger / Select.Options 등을 포함하는 children
|
|
23
|
+
*/
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Options 컨테이너 props
|
|
28
|
+
*/
|
|
29
|
+
export interface SelectOptionsProps extends HTMLProps<HTMLDivElement> {
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 개별 Option props
|
|
33
|
+
*/
|
|
34
|
+
export interface SelectOptionProps extends HTMLProps<HTMLDivElement> {
|
|
35
|
+
/**
|
|
36
|
+
* 옵션의 실제 값
|
|
37
|
+
*/
|
|
38
|
+
value: string;
|
|
39
|
+
/**
|
|
40
|
+
* 비활성화 여부
|
|
41
|
+
*/
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* 표시될 내용
|
|
45
|
+
*/
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Trigger 버튼 props
|
|
50
|
+
*/
|
|
51
|
+
export interface SelectTriggerProps extends HTMLProps<HTMLDivElement> {
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Select';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TooltipProps } from './Tooltip.type';
|
|
2
|
+
/**
|
|
3
|
+
* 범용 Tooltip 컴포넌트
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* <Tooltip popover="안녕하세요" direction="top">
|
|
7
|
+
* <button>Hover me</button>
|
|
8
|
+
* </Tooltip>
|
|
9
|
+
*/
|
|
10
|
+
export declare const Tooltip: ({ direction, popover, children, key, gap, autoFlip }: TooltipProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsxs } from 'react/jsx-runtime';
|
|
2
|
+
import { useState, useRef } from 'react';
|
|
3
|
+
import usePortal from '../hooks/usePortal.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 범용 Tooltip 컴포넌트
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <Tooltip popover="안녕하세요" direction="top">
|
|
10
|
+
* <button>Hover me</button>
|
|
11
|
+
* </Tooltip>
|
|
12
|
+
*/
|
|
13
|
+
const Tooltip = ({ direction = 'top', popover, children, key, gap = 0, autoFlip = true }) => {
|
|
14
|
+
const [visible, setVisible] = useState(false);
|
|
15
|
+
const targetRef = useRef(null);
|
|
16
|
+
const popoverRef = useRef(null);
|
|
17
|
+
const { portal, rootDom } = usePortal({
|
|
18
|
+
content: popover,
|
|
19
|
+
key,
|
|
20
|
+
visible,
|
|
21
|
+
targetRef,
|
|
22
|
+
popoverRef,
|
|
23
|
+
direction,
|
|
24
|
+
gap,
|
|
25
|
+
autoFlip,
|
|
26
|
+
});
|
|
27
|
+
const handleMouseEnter = () => {
|
|
28
|
+
setVisible(true);
|
|
29
|
+
};
|
|
30
|
+
const handleMouseLeave = () => {
|
|
31
|
+
setVisible(false);
|
|
32
|
+
};
|
|
33
|
+
return (jsxs("span", { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, ref: targetRef, children: [children, rootDom && visible && portal] }));
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export { Tooltip };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { Direction } from '../hooks';
|
|
3
|
+
export interface TooltipProps {
|
|
4
|
+
/** 툴팁을 띄울 대상 요소 */
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** 툴팁에 표시될 내용 */
|
|
7
|
+
popover: ReactNode;
|
|
8
|
+
/** 툴팁이 나타날 방향 */
|
|
9
|
+
direction: Direction;
|
|
10
|
+
/** 특정 DOM 타겟 ID (선택) */
|
|
11
|
+
targetId?: string;
|
|
12
|
+
/** 포털로 렌더링할 DOM 노드 */
|
|
13
|
+
domNode?: Element;
|
|
14
|
+
/** React key 값 */
|
|
15
|
+
key?: string;
|
|
16
|
+
/** 타겟과 툴팁 사이 간격(px) */
|
|
17
|
+
gap?: number;
|
|
18
|
+
/** 화면 밖으로 나가면 자동 반전 여부 */
|
|
19
|
+
autoFlip?: boolean;
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { TextInputProps } from './TextInput.type';
|
|
2
|
+
/**
|
|
3
|
+
* 숫자 전용 입력 컴포넌트 props
|
|
4
|
+
*
|
|
5
|
+
* TextInput을 기반으로 하며,
|
|
6
|
+
* 숫자 입력에 특화된 기능을 제공합니다.
|
|
7
|
+
*/
|
|
8
|
+
export interface NumberInputProps extends TextInputProps {
|
|
9
|
+
/**
|
|
10
|
+
* 최대 허용 값
|
|
11
|
+
*
|
|
12
|
+
* 초과 시:
|
|
13
|
+
* - 입력 제한
|
|
14
|
+
* - 또는 validation 에러 처리 가능
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* max={100}
|
|
18
|
+
*/
|
|
19
|
+
max?: number;
|
|
20
|
+
/**
|
|
21
|
+
* 천 단위 구분자 사용 여부
|
|
22
|
+
*
|
|
23
|
+
* true일 경우:
|
|
24
|
+
* 10000 → 10,000
|
|
25
|
+
*
|
|
26
|
+
* 내부 값은 parse 단계에서 콤마 제거 권장
|
|
27
|
+
*
|
|
28
|
+
* @default false
|
|
29
|
+
*/
|
|
30
|
+
useThousandsSeparator?: boolean;
|
|
31
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { TextInputProps } from './TextInput.type';
|
|
2
|
+
declare const TextInput: ({ maxLength, onChange, pattern, onValidate, validator, onCompositionStart, onCompositionEnd, disallowPattern, trimWhitespace, debounceMs, throttleMs, onDebouncedChange, onThrottledChange, onBlur, ...props }: TextInputProps) => import("react/jsx-runtime").JSX.Element;
|
|
3
|
+
export default TextInput;
|