jy-headless 0.3.16 → 0.3.17

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.
Files changed (47) hide show
  1. package/dist/Autocomplete/Autocomplete.js +1 -0
  2. package/dist/Autocomplete/Autocomplete.js.map +1 -0
  3. package/dist/Input/NumberInput.js +1 -0
  4. package/dist/Input/NumberInput.js.map +1 -0
  5. package/dist/Input/TextInput.js +1 -0
  6. package/dist/Input/TextInput.js.map +1 -0
  7. package/dist/Select/Select.js +1 -0
  8. package/dist/Select/Select.js.map +1 -0
  9. package/dist/Tooltip/Tooltip.js +1 -0
  10. package/dist/Tooltip/Tooltip.js.map +1 -0
  11. package/dist/hooks/useDebounce.js +1 -0
  12. package/dist/hooks/useDebounce.js.map +1 -0
  13. package/dist/hooks/usePortal.js +1 -0
  14. package/dist/hooks/usePortal.js.map +1 -0
  15. package/dist/hooks/useThrottle.js +1 -0
  16. package/dist/hooks/useThrottle.js.map +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -0
  19. package/package.json +4 -3
  20. package/dist/cjs/Autocomplete/Autocomplete.d.ts +0 -110
  21. package/dist/cjs/Autocomplete/Autocomplete.js +0 -404
  22. package/dist/cjs/Autocomplete/Autocomplete.type.d.ts +0 -37
  23. package/dist/cjs/Autocomplete/index.d.ts +0 -2
  24. package/dist/cjs/Input/NumberInput.d.ts +0 -55
  25. package/dist/cjs/Input/NumberInput.js +0 -93
  26. package/dist/cjs/Input/NumberInput.type.d.ts +0 -31
  27. package/dist/cjs/Input/TextInput.d.ts +0 -98
  28. package/dist/cjs/Input/TextInput.js +0 -174
  29. package/dist/cjs/Input/TextInput.type.d.ts +0 -95
  30. package/dist/cjs/Input/index.d.ts +0 -4
  31. package/dist/cjs/Select/Select.d.ts +0 -51
  32. package/dist/cjs/Select/Select.js +0 -179
  33. package/dist/cjs/Select/Select.type.d.ts +0 -52
  34. package/dist/cjs/Select/index.d.ts +0 -2
  35. package/dist/cjs/Tooltip/Tooltip.d.ts +0 -10
  36. package/dist/cjs/Tooltip/Tooltip.js +0 -38
  37. package/dist/cjs/Tooltip/Tooltip.type.d.ts +0 -20
  38. package/dist/cjs/Tooltip/index.d.ts +0 -2
  39. package/dist/cjs/hooks/index.d.ts +0 -3
  40. package/dist/cjs/hooks/useDebounce.d.ts +0 -1
  41. package/dist/cjs/hooks/useDebounce.js +0 -24
  42. package/dist/cjs/hooks/usePortal.d.ts +0 -23
  43. package/dist/cjs/hooks/usePortal.js +0 -80
  44. package/dist/cjs/hooks/useThrottle.d.ts +0 -1
  45. package/dist/cjs/hooks/useThrottle.js +0 -34
  46. package/dist/cjs/index.d.ts +0 -5
  47. package/dist/cjs/index.js +0 -23
@@ -400,3 +400,4 @@ const Autocomplete = Object.assign(AutocompleteContainer, {
400
400
  });
401
401
 
402
402
  export { Autocomplete };
403
+ //# sourceMappingURL=Autocomplete.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Autocomplete.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -89,3 +89,4 @@ const NumberInput = ({ max, useThousandsSeparator, onChange, ...props }) => {
89
89
  };
90
90
 
91
91
  export { NumberInput };
92
+ //# sourceMappingURL=NumberInput.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NumberInput.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -170,3 +170,4 @@ const TextInput = forwardRef(({ maxLength, onChange, pattern, onValidate, valida
170
170
  TextInput.displayName = 'TextInput';
171
171
 
172
172
  export { TextInput };
173
+ //# sourceMappingURL=TextInput.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TextInput.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -174,3 +174,4 @@ const Select = Object.assign(SelectContainer, {
174
174
  });
175
175
 
176
176
  export { Select, useSelectContext };
177
+ //# sourceMappingURL=Select.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Select.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -34,3 +34,4 @@ const Tooltip = ({ direction = 'top', popover, children, key, gap = 0, autoFlip
34
34
  };
35
35
 
36
36
  export { Tooltip };
37
+ //# sourceMappingURL=Tooltip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Tooltip.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -20,3 +20,4 @@ const useDebounce = (callback, delay) => {
20
20
  };
21
21
 
22
22
  export { useDebounce };
23
+ //# sourceMappingURL=useDebounce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDebounce.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;"}
@@ -76,3 +76,4 @@ const usePortal = ({ content, key, visible = true, targetRef, popoverRef, direct
76
76
  };
77
77
 
78
78
  export { usePortal as default };
79
+ //# sourceMappingURL=usePortal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usePortal.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -30,3 +30,4 @@ const useThrottle = (callback, delay) => {
30
30
  };
31
31
 
32
32
  export { useThrottle };
33
+ //# sourceMappingURL=useThrottle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useThrottle.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/dist/index.js CHANGED
@@ -8,3 +8,4 @@ export { NumberInput } from './Input/NumberInput.js';
8
8
  export { TextInput } from './Input/TextInput.js';
9
9
  export { Select, useSelectContext } from './Select/Select.js';
10
10
  export { Autocomplete } from './Autocomplete/Autocomplete.js';
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;"}
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "jy-headless",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "description": "A lightweight and customizable headless UI library for React components",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "sideEffects": false,
8
9
  "repository": "https://github.com/yCZwIqY/jy-headless",
9
10
  "scripts": {
10
11
  "build": " rollup -c",
@@ -18,8 +19,8 @@
18
19
  "exports": {
19
20
  ".": {
20
21
  "import": "./dist/index.js",
21
- "require": "./dist/cjs/index.js",
22
- "types": "./dist/index.d.ts"
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
23
24
  }
24
25
  },
25
26
  "keywords": [
@@ -1,110 +0,0 @@
1
- import React from 'react';
2
- import type { AutocompleteInputProps, AutocompleteOptionProps, AutocompleteOptionsProps, AutocompleteProps } from './Autocomplete.type';
3
- /**
4
- * 범용 Autocomplete(Combobox) 컴포넌트 (Compound API)
5
- *
6
- * - Compound 구성: <Autocomplete> + <Autocomplete.Input /> + <Autocomplete.Options /> + <Autocomplete.Option />
7
- * - a11y: combobox/listbox/option role + aria-controls/activedescendant + aria-live(결과 수 안내)
8
- * - 키보드: ArrowUp/Down 이동, Enter 선택, Escape 닫기, Tab 닫기
9
- * - IME(한글/일본어) 조합 입력 중에는 방향키/엔터 선택 로직을 막아 UX를 보호
10
- * - Options는 portal로 렌더링되며, outside click 시 닫힘
11
- *
12
- * ## 모드
13
- * 1) items 모드 (권장)
14
- * - <Autocomplete.Options items={...} renderItem={...} />
15
- * - 내부에서 filterFn으로 filtered를 만들고, Options는 filtered를 가상화로 렌더
16
- *
17
- * 2) children 모드 (간단 리스트)
18
- * - <Autocomplete.Options> 안에 <Autocomplete.Option />을 직접 나열
19
- * - 소규모 옵션에만 권장 (가상화/activeIndex 기반 aria-activedescendant는 items 모드가 더 적합)
20
- *
21
- * @example
22
- * // 1) items 모드 (기본 / 권장)
23
- * const items = [
24
- * { value: 'apple', label: 'Apple' },
25
- * { value: 'banana', label: 'Banana' },
26
- * { value: 'grape', label: 'Grape' },
27
- * ];
28
- *
29
- * const [value, setValue] = useState<string | null>(null);
30
- *
31
- * <Autocomplete value={value} onChange={setValue}>
32
- * <Autocomplete.Input placeholder="과일을 검색하세요" />
33
- * <Autocomplete.Options
34
- * items={items}
35
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
36
- * />
37
- * </Autocomplete>
38
- *
39
- * @example
40
- * // 2) 입력값(query)까지 controlled로 관리하고 싶을 때
41
- * const [value, setValue] = useState<string | null>(null);
42
- * const [inputValue, setInputValue] = useState('');
43
- *
44
- * <Autocomplete
45
- * value={value}
46
- * onChange={setValue}
47
- * inputValue={inputValue}
48
- * onInputChange={setInputValue}
49
- * >
50
- * <Autocomplete.Input placeholder="검색어 제어" />
51
- * <Autocomplete.Options
52
- * items={items}
53
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
54
- * />
55
- * </Autocomplete>
56
- *
57
- * @example
58
- * // 3) filterFn 커스터마이징 (prefix match)
59
- * const startsWithFilter = (item, query) =>
60
- * item.label.toLowerCase().startsWith(query.trim().toLowerCase());
61
- *
62
- * <Autocomplete value={value} onChange={setValue} filterFn={startsWithFilter}>
63
- * <Autocomplete.Input placeholder="앞글자 매칭" />
64
- * <Autocomplete.Options
65
- * items={items}
66
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
67
- * />
68
- * </Autocomplete>
69
- *
70
- * @example
71
- * // 4) 가상화 옵션 조정 (많은 데이터)
72
- * <Autocomplete value={value} onChange={setValue}>
73
- * <Autocomplete.Input placeholder="대량 데이터" />
74
- * <Autocomplete.Options
75
- * items={bigItems}
76
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
77
- * itemHeight={36}
78
- * maxVisibleItems={8}
79
- * overscan={3}
80
- * />
81
- * </Autocomplete>
82
- *
83
- * @example
84
- * // 5) disabled
85
- * <Autocomplete value={value} onChange={setValue} disabled>
86
- * <Autocomplete.Input placeholder="비활성화" />
87
- * <Autocomplete.Options
88
- * items={items}
89
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
90
- * />
91
- * </Autocomplete>
92
- *
93
- * @example
94
- * // 6) children 모드 (소규모 리스트에만 권장)
95
- * const [value, setValue] = useState<string | null>(null);
96
- *
97
- * <Autocomplete value={value} onChange={setValue}>
98
- * <Autocomplete.Input placeholder="직접 옵션 나열" />
99
- * <Autocomplete.Options>
100
- * <Autocomplete.Option value="seoul" label="Seoul" />
101
- * <Autocomplete.Option value="busan" label="Busan" />
102
- * <Autocomplete.Option value="jeju" label="Jeju" disabled />
103
- * </Autocomplete.Options>
104
- * </Autocomplete>
105
- */
106
- export declare const Autocomplete: (({ value, onChange, inputValue, onInputChange, disabled, filterFn, children, }: AutocompleteProps) => import("react/jsx-runtime").JSX.Element) & {
107
- Input: ({ onKeyDown, onFocus, onChange, onCompositionStart, onCompositionEnd, ...props }: AutocompleteInputProps) => import("react/jsx-runtime").JSX.Element;
108
- Options: ({ items, renderItem, itemHeight, maxVisibleItems, overscan, children, ...props }: AutocompleteOptionsProps) => React.ReactPortal | null;
109
- Option: ({ value, label, disabled, children, ...props }: AutocompleteOptionProps) => import("react/jsx-runtime").JSX.Element | null;
110
- };
@@ -1,404 +0,0 @@
1
- 'use strict';
2
-
3
- var jsxRuntime = require('react/jsx-runtime');
4
- var react = require('react');
5
- var usePortal = require('../hooks/usePortal.js');
6
- var TextInput = require('../Input/TextInput.js');
7
-
8
- const AutocompleteContext = react.createContext(null);
9
- const useAutocomplete = () => {
10
- const ctx = react.useContext(AutocompleteContext);
11
- if (!ctx)
12
- throw new Error('Autocomplete components must be used within <Autocomplete>');
13
- return ctx;
14
- };
15
- const defaultFilter = (item, query) => item.label.toLowerCase().includes(query.trim().toLowerCase());
16
- /**
17
- * Root
18
- */
19
- const AutocompleteContainer = ({ value, onChange, inputValue, onInputChange, disabled, filterFn = defaultFilter, children, }) => {
20
- const [open, setOpen] = react.useState(false);
21
- const [activeIndex, setActiveIndex] = react.useState(-1);
22
- const inputRef = react.useRef(null);
23
- // controlled/uncontrolled query
24
- const [internalQuery, setInternalQuery] = react.useState('');
25
- const query = inputValue ?? internalQuery;
26
- const setQuery = (v) => {
27
- onInputChange?.(v);
28
- if (inputValue === undefined)
29
- setInternalQuery(v);
30
- };
31
- const listboxId = react.useId();
32
- // NOTE:
33
- // - virtualization을 제대로 하려면 options data(items)가 Root에 필요하지만,
34
- // compound API 유지를 위해 Options에서 items를 주입받아 Root에서 filtered를 만들기 어렵다.
35
- // 그래서 Root에서는 "filtered"를 Options에서 제공할 수 있게 설계하면 복잡해짐.
36
- // ✅ 해결: Options에 items를 주면 Root가 접근할 수 있도록 "itemsRef"를 둔다.
37
- const itemsRef = react.useRef([]);
38
- const setItems = (items) => {
39
- itemsRef.current = items;
40
- };
41
- const filtered = react.useMemo(() => {
42
- const src = itemsRef.current ?? [];
43
- if (!query.trim())
44
- return src;
45
- return src.filter((it) => filterFn(it, query));
46
- }, [query, filterFn]);
47
- const close = () => {
48
- setOpen(false);
49
- setActiveIndex(-1);
50
- };
51
- const commitByIndex = (index) => {
52
- const item = filtered[index];
53
- if (!item || item.disabled)
54
- return;
55
- onChange(item.value);
56
- setQuery(item.label);
57
- close();
58
- };
59
- // 외부 value 변경 시 input label 동기화
60
- react.useEffect(() => {
61
- if (!value)
62
- return;
63
- const found = itemsRef.current.find((i) => i.value === value);
64
- if (found)
65
- setQuery(found.label);
66
- // eslint-disable-next-line react-hooks/exhaustive-deps
67
- }, [value]);
68
- const statusText = react.useMemo(() => {
69
- if (!open)
70
- return '';
71
- const n = filtered.length;
72
- if (n === 0)
73
- return 'No results.';
74
- if (n === 1)
75
- return '1 result available.';
76
- return `${n} results available.`;
77
- }, [open, filtered.length]);
78
- return (jsxRuntime.jsxs(AutocompleteContext.Provider, { value: {
79
- open,
80
- setOpen,
81
- selectedValue: value,
82
- setSelectedValue: onChange,
83
- query,
84
- setQuery,
85
- disabled,
86
- inputRef,
87
- listboxId,
88
- activeIndex,
89
- setActiveIndex,
90
- filtered,
91
- statusText,
92
- commitByIndex,
93
- close,
94
- }, children: [jsxRuntime.jsx(ItemsBridge, { onItems: setItems }), children] }));
95
- };
96
- /**
97
- * Options에서 items를 주입해주기 위한 브릿지(보이지 않는 컴포넌트)
98
- * - Options가 items prop을 받으면, 내부에서 window.__ 같은 걸 쓰지 않고 Root ref에 주입
99
- */
100
- const ItemsBridgeContext = react.createContext(null);
101
- const ItemsBridge = ({ onItems }) => {
102
- return jsxRuntime.jsx(ItemsBridgeContext.Provider, { value: onItems });
103
- };
104
- const useItemsBridge = () => react.useContext(ItemsBridgeContext);
105
- /**
106
- * Input (Combobox Trigger)
107
- * - 포커스는 input 유지
108
- * - aria-activedescendant로 active option을 알려줌
109
- */
110
- // ...생략
111
- const Input = ({ onKeyDown, onFocus, onChange, onCompositionStart, onCompositionEnd, ...props }) => {
112
- const { open, setOpen, query, setQuery, disabled, listboxId, activeIndex, setActiveIndex, filtered, commitByIndex, close, inputRef, } = useAutocomplete();
113
- // ✅ IME 조합 중에는 방향키/엔터로 옵션 선택하지 않게 막기
114
- const composingRef = react.useRef(false);
115
- const activeId = activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined;
116
- const move = (delta) => {
117
- if (!filtered.length)
118
- return;
119
- setOpen(true);
120
- setActiveIndex(activeIndex < 0 ? 0 : (activeIndex + delta + filtered.length) % filtered.length);
121
- };
122
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(TextInput.TextInput, { ref: inputRef, role: 'combobox', "aria-autocomplete": 'list', "aria-expanded": open, "aria-controls": listboxId, "aria-activedescendant": activeId, disabled: disabled, value: query, onFocus: (e) => {
123
- if (!disabled) {
124
- setOpen(true);
125
- setActiveIndex(filtered.length ? 0 : -1);
126
- }
127
- onFocus?.(e);
128
- }, onChange: (e) => {
129
- if (disabled)
130
- return;
131
- setQuery(e.target.value);
132
- setOpen(true);
133
- setActiveIndex(0);
134
- onChange?.(e);
135
- }, onCompositionStart: (e) => {
136
- composingRef.current = true;
137
- onCompositionStart?.(e);
138
- }, onCompositionEnd: (e) => {
139
- composingRef.current = false;
140
- onCompositionEnd?.(e);
141
- }, onKeyDown: (e) => {
142
- if (disabled)
143
- return;
144
- // ✅ 조합중이면 Autocomplete 키처리 하지 않음
145
- if (composingRef.current) {
146
- onKeyDown?.(e);
147
- return;
148
- }
149
- switch (e.key) {
150
- case 'ArrowDown':
151
- e.preventDefault();
152
- move(1);
153
- break;
154
- case 'ArrowUp':
155
- e.preventDefault();
156
- move(-1);
157
- break;
158
- case 'Enter':
159
- if (open && activeIndex >= 0) {
160
- e.preventDefault();
161
- commitByIndex(activeIndex);
162
- }
163
- break;
164
- case 'Escape':
165
- e.preventDefault();
166
- close();
167
- break;
168
- case 'Tab':
169
- close();
170
- break;
171
- }
172
- onKeyDown?.(e);
173
- }, ...props }), jsxRuntime.jsx("span", { "aria-live": 'polite', style: {
174
- position: 'absolute',
175
- width: 1,
176
- height: 1,
177
- overflow: 'hidden',
178
- clip: 'rect(0 0 0 0)',
179
- whiteSpace: 'nowrap',
180
- }, children: open ? `${filtered.length} results.` : '' })] }));
181
- };
182
- /**
183
- * Options
184
- * - portal
185
- * - outside click close
186
- * - virtualization (items + renderItem provided)
187
- */
188
- const Options = ({ items, renderItem, itemHeight = 36, maxVisibleItems = 8, overscan = 3, children, ...props }) => {
189
- const bridge = useItemsBridge();
190
- const { open, setOpen, close, inputRef, listboxId, filtered, activeIndex, setActiveIndex, commitByIndex, } = useAutocomplete();
191
- // items 주입(가상화 모드)
192
- react.useEffect(() => {
193
- if (items && bridge)
194
- bridge(items);
195
- }, [items, bridge]);
196
- const popoverRef = react.useRef(null);
197
- const triggerWidth = inputRef.current?.getBoundingClientRect().width;
198
- // outside click
199
- react.useEffect(() => {
200
- if (!open)
201
- return;
202
- const onDown = (e) => {
203
- const t = e.target;
204
- if (inputRef.current?.contains(t) || popoverRef.current?.contains(t))
205
- return;
206
- close();
207
- };
208
- document.addEventListener('mousedown', onDown);
209
- return () => document.removeEventListener('mousedown', onDown);
210
- }, [open, close, inputRef]);
211
- // scroll container ref for virtualization
212
- const scrollRef = react.useRef(null);
213
- // active option이 항상 보이게 스크롤 보정
214
- react.useEffect(() => {
215
- if (!open)
216
- return;
217
- if (!scrollRef.current)
218
- return;
219
- if (activeIndex < 0)
220
- return;
221
- const top = activeIndex * itemHeight;
222
- const bottom = top + itemHeight;
223
- const viewTop = scrollRef.current.scrollTop;
224
- const viewBottom = viewTop + scrollRef.current.clientHeight;
225
- if (top < viewTop)
226
- scrollRef.current.scrollTop = top;
227
- else if (bottom > viewBottom)
228
- scrollRef.current.scrollTop = bottom - scrollRef.current.clientHeight;
229
- }, [open, activeIndex, itemHeight]);
230
- // virtualization range
231
- const total = filtered.length;
232
- const viewportCount = Math.min(maxVisibleItems, Math.max(1, total));
233
- const viewportHeight = viewportCount * itemHeight;
234
- const [scrollTop, setScrollTop] = react.useState(0);
235
- const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
236
- const endIndex = Math.min(total, Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan);
237
- const visible = filtered.slice(startIndex, endIndex);
238
- const { portal } = usePortal({
239
- visible: open,
240
- targetRef: inputRef,
241
- popoverRef,
242
- direction: 'bottom',
243
- gap: 4,
244
- content: (jsxRuntime.jsx("div", { ref: popoverRef, style: { width: triggerWidth }, ...props, children: items && renderItem ? (jsxRuntime.jsx("div", { id: listboxId, role: 'listbox', ref: scrollRef, style: { maxHeight: viewportHeight, overflow: 'auto', position: 'relative' }, onScroll: (e) => setScrollTop(e.target.scrollTop), children: jsxRuntime.jsx("div", { style: { height: total * itemHeight, position: 'relative' }, children: visible.map((item, i) => {
245
- const index = startIndex + i; // filtered index
246
- const isActive = index === activeIndex;
247
- const isSelected = item.value === undefined; // selection 표시를 더 강하게 원하면 ctx.selectedValue 비교해서 쓰면 됨
248
- return (jsxRuntime.jsx("div", { id: `${listboxId}-opt-${index}`, role: 'option', "aria-selected": isSelected, "aria-disabled": item.disabled, "data-active": isActive, tabIndex: -1, onMouseEnter: () => setActiveIndex(index), onMouseDown: (e) => {
249
- // 클릭 시 input blur 방지(중요)
250
- e.preventDefault();
251
- }, onClick: () => commitByIndex(index), style: {
252
- position: 'absolute',
253
- top: index * itemHeight,
254
- left: 0,
255
- right: 0,
256
- height: itemHeight,
257
- display: 'flex',
258
- alignItems: 'center',
259
- }, children: renderItem(item) }, item.value));
260
- }) }) })) : (
261
- /* ✅ children 모드(비가상화) */
262
- jsxRuntime.jsx("div", { id: listboxId, role: 'listbox', children: children })) })),
263
- });
264
- return open ? portal : null;
265
- };
266
- /**
267
- * Option (children 모드 전용 / small list)
268
- * - a11y option role/ids만 최소 보장
269
- * - 가상화는 여기엔 적용하지 않음
270
- */
271
- const Option = ({ value, label, disabled, children, ...props }) => {
272
- const { listboxId, filtered, query, open, setOpen, setQuery, setSelectedValue, activeIndex, setActiveIndex, } = useAutocomplete();
273
- // children 모드에서 필터링은 “간단 버전”
274
- const visible = react.useMemo(() => {
275
- if (!query.trim())
276
- return true;
277
- return label.toLowerCase().includes(query.trim().toLowerCase());
278
- }, [label, query]);
279
- react.useMemo(() => {
280
- // filtered는 items 모드에서만 의미있음.
281
- // children 모드는 간단히 -1 처리(aria-activedescendant는 items 모드가 권장)
282
- return -1;
283
- }, []);
284
- if (!visible)
285
- return null;
286
- return (jsxRuntime.jsx("div", { role: "option", "aria-disabled": disabled, "aria-selected": false, tabIndex: -1, onMouseDown: (e) => e.preventDefault(), onClick: () => {
287
- if (disabled)
288
- return;
289
- setSelectedValue(value);
290
- setQuery(label);
291
- setOpen(false);
292
- setActiveIndex(-1);
293
- }, ...props, children: children ?? label }));
294
- };
295
- /**
296
- * 범용 Autocomplete(Combobox) 컴포넌트 (Compound API)
297
- *
298
- * - Compound 구성: <Autocomplete> + <Autocomplete.Input /> + <Autocomplete.Options /> + <Autocomplete.Option />
299
- * - a11y: combobox/listbox/option role + aria-controls/activedescendant + aria-live(결과 수 안내)
300
- * - 키보드: ArrowUp/Down 이동, Enter 선택, Escape 닫기, Tab 닫기
301
- * - IME(한글/일본어) 조합 입력 중에는 방향키/엔터 선택 로직을 막아 UX를 보호
302
- * - Options는 portal로 렌더링되며, outside click 시 닫힘
303
- *
304
- * ## 모드
305
- * 1) items 모드 (권장)
306
- * - <Autocomplete.Options items={...} renderItem={...} />
307
- * - 내부에서 filterFn으로 filtered를 만들고, Options는 filtered를 가상화로 렌더
308
- *
309
- * 2) children 모드 (간단 리스트)
310
- * - <Autocomplete.Options> 안에 <Autocomplete.Option />을 직접 나열
311
- * - 소규모 옵션에만 권장 (가상화/activeIndex 기반 aria-activedescendant는 items 모드가 더 적합)
312
- *
313
- * @example
314
- * // 1) items 모드 (기본 / 권장)
315
- * const items = [
316
- * { value: 'apple', label: 'Apple' },
317
- * { value: 'banana', label: 'Banana' },
318
- * { value: 'grape', label: 'Grape' },
319
- * ];
320
- *
321
- * const [value, setValue] = useState<string | null>(null);
322
- *
323
- * <Autocomplete value={value} onChange={setValue}>
324
- * <Autocomplete.Input placeholder="과일을 검색하세요" />
325
- * <Autocomplete.Options
326
- * items={items}
327
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
328
- * />
329
- * </Autocomplete>
330
- *
331
- * @example
332
- * // 2) 입력값(query)까지 controlled로 관리하고 싶을 때
333
- * const [value, setValue] = useState<string | null>(null);
334
- * const [inputValue, setInputValue] = useState('');
335
- *
336
- * <Autocomplete
337
- * value={value}
338
- * onChange={setValue}
339
- * inputValue={inputValue}
340
- * onInputChange={setInputValue}
341
- * >
342
- * <Autocomplete.Input placeholder="검색어 제어" />
343
- * <Autocomplete.Options
344
- * items={items}
345
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
346
- * />
347
- * </Autocomplete>
348
- *
349
- * @example
350
- * // 3) filterFn 커스터마이징 (prefix match)
351
- * const startsWithFilter = (item, query) =>
352
- * item.label.toLowerCase().startsWith(query.trim().toLowerCase());
353
- *
354
- * <Autocomplete value={value} onChange={setValue} filterFn={startsWithFilter}>
355
- * <Autocomplete.Input placeholder="앞글자 매칭" />
356
- * <Autocomplete.Options
357
- * items={items}
358
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
359
- * />
360
- * </Autocomplete>
361
- *
362
- * @example
363
- * // 4) 가상화 옵션 조정 (많은 데이터)
364
- * <Autocomplete value={value} onChange={setValue}>
365
- * <Autocomplete.Input placeholder="대량 데이터" />
366
- * <Autocomplete.Options
367
- * items={bigItems}
368
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
369
- * itemHeight={36}
370
- * maxVisibleItems={8}
371
- * overscan={3}
372
- * />
373
- * </Autocomplete>
374
- *
375
- * @example
376
- * // 5) disabled
377
- * <Autocomplete value={value} onChange={setValue} disabled>
378
- * <Autocomplete.Input placeholder="비활성화" />
379
- * <Autocomplete.Options
380
- * items={items}
381
- * renderItem={(item) => <div style={{ padding: 8 }}>{item.label}</div>}
382
- * />
383
- * </Autocomplete>
384
- *
385
- * @example
386
- * // 6) children 모드 (소규모 리스트에만 권장)
387
- * const [value, setValue] = useState<string | null>(null);
388
- *
389
- * <Autocomplete value={value} onChange={setValue}>
390
- * <Autocomplete.Input placeholder="직접 옵션 나열" />
391
- * <Autocomplete.Options>
392
- * <Autocomplete.Option value="seoul" label="Seoul" />
393
- * <Autocomplete.Option value="busan" label="Busan" />
394
- * <Autocomplete.Option value="jeju" label="Jeju" disabled />
395
- * </Autocomplete.Options>
396
- * </Autocomplete>
397
- */
398
- const Autocomplete = Object.assign(AutocompleteContainer, {
399
- Input,
400
- Options,
401
- Option,
402
- });
403
-
404
- exports.Autocomplete = Autocomplete;
@@ -1,37 +0,0 @@
1
- import { HTMLAttributes, ReactNode } from 'react';
2
- import { TextInputProps } from '../Input/TextInput.type';
3
- export type AutocompleteItem = {
4
- value: string;
5
- label: string;
6
- disabled?: boolean;
7
- };
8
- export interface AutocompleteProps {
9
- value: string | null;
10
- onChange: (v: string | null) => void;
11
- inputValue?: string;
12
- onInputChange?: (v: string) => void;
13
- disabled?: boolean;
14
- /** 옵션 필터 커스터마이즈 */
15
- filterFn?: (item: AutocompleteItem, query: string) => boolean;
16
- children: ReactNode;
17
- }
18
- export interface AutocompleteInputProps extends TextInputProps {
19
- /** label 연결용 (없으면 aria-label 필수) */
20
- 'aria-label'?: string;
21
- }
22
- export interface AutocompleteOptionsProps extends HTMLAttributes<HTMLDivElement> {
23
- /** ✅ 가상화 모드: 데이터 기반 */
24
- items?: AutocompleteItem[];
25
- /** 가상화 모드에서 항목 렌더 */
26
- renderItem?: (item: AutocompleteItem) => ReactNode;
27
- /** virtualization 옵션 */
28
- itemHeight?: number;
29
- maxVisibleItems?: number;
30
- overscan?: number;
31
- }
32
- export interface AutocompleteOptionProps extends HTMLAttributes<HTMLDivElement> {
33
- /** children 모드(비가상화) */
34
- value: string;
35
- label: string;
36
- disabled?: boolean;
37
- }
@@ -1,2 +0,0 @@
1
- export * from './Autocomplete';
2
- export * from './Autocomplete.type';