@ihabdevteam/core 0.231.0 → 0.233.0
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/CHANGELOG.md +74 -0
- package/dist/components.cjs.js +179 -179
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +184 -184
- package/dist/ihabdevteam-core.d.ts +47 -6
- package/dist/packages/core/src/components/BulkBar.js +5 -1
- package/dist/packages/core/src/components/TextField.css +34 -0
- package/dist/packages/core/src/components/TextField.d.ts +11 -1
- package/dist/packages/core/src/components/TextField.js +8 -3
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +35 -4
- package/dist/packages/core/src/components/useComboboxSuggestions.js +27 -11
- package/dist/packages/core/src/components.d.ts +1 -1
- package/dist/packages/core/src/i18n/locales/en.json +4 -0
- package/dist/packages/core/src/i18n/locales/ko.json +4 -0
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -1626,7 +1626,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1626
1626
|
*
|
|
1627
1627
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1628
1628
|
*/
|
|
1629
|
-
export declare const CORE_VERSION = "0.
|
|
1629
|
+
export declare const CORE_VERSION = "0.233.0";
|
|
1630
1630
|
|
|
1631
1631
|
/** @public */
|
|
1632
1632
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -7630,11 +7630,34 @@ export declare interface TextFieldProps {
|
|
|
7630
7630
|
* 자동완성 후보 목록 — 지정하면 콤보박스(입력 중 후보 드롭다운 + 키보드 네비게이션)로 동작한다.
|
|
7631
7631
|
* 필터링은 현재 값 기준으로 동작하므로 controlled(value+onChange) 사용을 전제로 한다.
|
|
7632
7632
|
*/
|
|
7633
|
-
|
|
7633
|
+
/** 자동완성 후보. 글자 묶음, 또는 `{ id, label, hint? }` 줄 묶음(말귀 요청 ㊻). */
|
|
7634
|
+
suggestions?: (string | TextFieldSuggestion)[];
|
|
7635
|
+
/** 거르는 법. `false` 면 **안 거른다**(부르는 쪽이 이미 걸러 준 것). 기본은 `label` 에
|
|
7636
|
+
* 글자가 들어 있나. 초성 찾기처럼 셈이 다른 자리에서 바꾼다. */
|
|
7637
|
+
filterSuggestions?: false | ((suggestions: (string | TextFieldSuggestion)[], value: string) => (string | TextFieldSuggestion)[]);
|
|
7638
|
+
/** 주면 **고를 때 칸에 값을 안 넣고** 고른 줄을 그대로 준다 — '값 채우기' 가 아니라
|
|
7639
|
+
* '어디로 가기' 인 자리에 쓴다. */
|
|
7640
|
+
onSelectSuggestion?: (item: string | TextFieldSuggestion) => void;
|
|
7641
|
+
/** 목록이 뜨면 첫 줄을 바로 짚는다. 기본 거짓 — `useComboboxSuggestions` 의 같은 이름 참고. */
|
|
7642
|
+
autoActiveFirst?: boolean;
|
|
7634
7643
|
/** 포커스 시 입력이 비어 있어도 전체 후보 목록을 표시 (suggestions와 함께 사용) */
|
|
7635
7644
|
openOnFocus?: boolean;
|
|
7636
7645
|
}
|
|
7637
7646
|
|
|
7647
|
+
/**
|
|
7648
|
+
* 후보 한 줄 — 글자 하나로는 못 담는 자리(말귀 요청 ㊻, 2026-10-01).
|
|
7649
|
+
*
|
|
7650
|
+
* `label` 이 줄에 적히고 **거르기의 기본 대상**이며, `hint` 는 그 오른쪽에 흐리게 서는
|
|
7651
|
+
* 갈래 글자(`숙제` · `나의 단어표` 같은 것)다. `id` 는 고른 뒤 부르는 쪽이 무엇을 골랐는지
|
|
7652
|
+
* 알아보는 열쇠 — 이름이 같은 두 줄을 가릴 수 있어야 한다.
|
|
7653
|
+
* @public
|
|
7654
|
+
*/
|
|
7655
|
+
export declare interface TextFieldSuggestion {
|
|
7656
|
+
id: string;
|
|
7657
|
+
label: string;
|
|
7658
|
+
hint?: string;
|
|
7659
|
+
}
|
|
7660
|
+
|
|
7638
7661
|
/**
|
|
7639
7662
|
* 테마 유틸리티
|
|
7640
7663
|
* - 'light' | 'dark' | 'auto'
|
|
@@ -9145,21 +9168,39 @@ export declare interface UseCalendarMonthResult {
|
|
|
9145
9168
|
* `handleCompositionEnd` 를 걸어야 한다(TextField 는 건다). 걸지 않으면 지금처럼 매 값마다 다시 거른다.
|
|
9146
9169
|
* @public
|
|
9147
9170
|
*/
|
|
9148
|
-
export declare function useComboboxSuggestions({ value, onChange, suggestions, openOnFocus, }: {
|
|
9171
|
+
export declare function useComboboxSuggestions<T extends string | TextFieldSuggestion = string>({ value, onChange, suggestions, openOnFocus, filterSuggestions, onSelectSuggestion, autoActiveFirst, }: {
|
|
9149
9172
|
value: string;
|
|
9150
9173
|
onChange?: (value: string) => void;
|
|
9151
|
-
|
|
9174
|
+
/** 글자 묶음, 또는 `{ id, label, hint? }` 줄 묶음 */
|
|
9175
|
+
suggestions: T[];
|
|
9152
9176
|
openOnFocus?: boolean;
|
|
9177
|
+
/** 거르는 법을 바꾼다. `false` 면 **안 거른다** — 부르는 쪽이 이미 걸러 준 것으로 본다.
|
|
9178
|
+
* 기본은 `label` 에 글자가 들어 있나(대소문자 무시)다. 초성 찾기처럼 셈이 다른 자리에서
|
|
9179
|
+
* 쓴다(`matchesChoseongSearch` 는 이 꾸러미의 `utils` 에 있다). */
|
|
9180
|
+
filterSuggestions?: false | ((suggestions: T[], value: string) => T[]);
|
|
9181
|
+
/** 주면 **고를 때 칸에 값을 안 넣는다** — 고른 줄을 그대로 받는다. 고르는 것이 '값 채우기'
|
|
9182
|
+
* 가 아니라 '어디로 가기' 인 자리(화면 찾기 등)에 쓴다. */
|
|
9183
|
+
onSelectSuggestion?: (item: T) => void;
|
|
9184
|
+
/** 목록이 뜨면 **첫 줄을 바로 짚는다**(ARIA 의 automatic selection).
|
|
9185
|
+
*
|
|
9186
|
+
* 기본은 거짓 — 아무것도 안 짚은 채 열리고, 그 상태의 Enter 는 제안을 안 고른다
|
|
9187
|
+
* (친 글자를 그대로 쓰겠다는 뜻이다). 자유롭게 치는 칸에서는 그쪽이 맞다.
|
|
9188
|
+
* **고르는 것이 '어디로 가기' 인 칸에서는 참이 맞다** — 치자마자 Enter 로 첫 줄에 가고,
|
|
9189
|
+
* `aria-expanded` 가 참인데 짚은 줄이 없어 낭독기에 "목록이 떴다" 만 가고 **뭐가 떴는지는
|
|
9190
|
+
* 안 가는** 것도 함께 풀린다(말귀가 그래서 손수 0 으로 시작하게 두고 있었다).
|
|
9191
|
+
* `onSelectSuggestion` 과 **묶지 않았다** — 고르는 것이 가기인데도 친 글자를 그대로
|
|
9192
|
+
* 쓰고 싶은 칸이 있다(검색어를 그대로 보내는 자리). 서로 다른 결정이다. */
|
|
9193
|
+
autoActiveFirst?: boolean;
|
|
9153
9194
|
}): {
|
|
9154
9195
|
listId: string;
|
|
9155
9196
|
open: boolean;
|
|
9156
9197
|
activeIdx: number;
|
|
9157
|
-
filtered:
|
|
9198
|
+
filtered: T[];
|
|
9158
9199
|
handleFocus: () => void;
|
|
9159
9200
|
handleBlur: () => void;
|
|
9160
9201
|
handleInput: () => void;
|
|
9161
9202
|
handleKeyDown: (e: KeyboardEvent_2<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
9162
|
-
handleSelect: (item:
|
|
9203
|
+
handleSelect: (item: T) => void;
|
|
9163
9204
|
/** 입력칸의 onCompositionStart 에 건다 — 조합 중에는 걸리는 후보가 0 건이 되어도 직전 목록을 둔다 */
|
|
9164
9205
|
handleCompositionStart: () => void;
|
|
9165
9206
|
/** 입력칸의 onCompositionEnd 에 건다 — 조합이 끝나면 목록을 다시 계산한다 */
|
|
@@ -96,5 +96,9 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
|
|
|
96
96
|
단추가 Tab 순서와 낭독기에 남아, 보이지 않는 '선택 해제'·'삭제' 에 포커스가 갔다.
|
|
97
97
|
inert 로 포커스·낭독기에서 함께 뺀다(보이는 모양은 CSS 그대로). aria-hidden 은
|
|
98
98
|
inert 를 모르는 오래된 브라우저를 위한 덧댐이다. */
|
|
99
|
-
inert: !show || undefined, "aria-hidden": show ? undefined : true, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", role: "status", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (
|
|
99
|
+
inert: !show || undefined, "aria-hidden": show ? undefined : true, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", role: "status", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (
|
|
100
|
+
/* 보이는 것은 `3 / 20` 이지만 낭독기로는 **맨 숫자 둘**이라 뜻이 안 선다.
|
|
101
|
+
위 `role="status"` 가 고른 수는 말하는데 **천장(max)은 여기에만 있다**
|
|
102
|
+
(말귀 요청 ㊼ 를 받고 코어 안을 같은 눈으로 훑다가 나왔다). */
|
|
103
|
+
_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}`, "aria-label": t('common.selectedOfMax', { count, max, defaultValue: `최대 ${max}개 중 ${count}개` }) })), _jsxs("div", { className: `bulk-bar__actions${stacked ? ' bulk-bar__actions--stacked' : ''}`, children: [_jsx(Button, { htmlType: "button", size: 40, label: clearLabel, onClick: onClear }), actions.map((a, i) => (_jsx(Button, { htmlType: "button", size: 40, variant: a.variant ?? 'default', icon: a.icon, label: a.label, onClick: a.onClick }, i)))] })] }));
|
|
100
104
|
}
|
|
@@ -236,6 +236,9 @@
|
|
|
236
236
|
padding: 8px 12px;
|
|
237
237
|
cursor: pointer;
|
|
238
238
|
color: var(--color-darkGray);
|
|
239
|
+
/* 이름과 갈래(`hint`)가 한 줄에 선다 — 글자 하나만 주는 쪽에서는 자식이 하나라 그대로다 */
|
|
240
|
+
display: flex;
|
|
241
|
+
align-items: center;
|
|
239
242
|
}
|
|
240
243
|
.text-field__suggestion.is-active,
|
|
241
244
|
.text-field__suggestion:hover {
|
|
@@ -288,3 +291,34 @@
|
|
|
288
291
|
align-self: flex-end;
|
|
289
292
|
margin-bottom: var(--spacing-s);
|
|
290
293
|
}
|
|
294
|
+
|
|
295
|
+
/* 후보 줄 — 이름과 갈래(`hint`)가 한 줄에 선다(말귀 요청 ㊻, 2026-10-01).
|
|
296
|
+
갈래는 오른쪽 끝에 흐리게. 이름이 길면 이름이 먼저 줄고 갈래는 안 줄인다 —
|
|
297
|
+
`숙제` 와 `나의 단어표` 를 가리는 것이 이 글자가 선 까닭이라 잘리면 뜻이 없다. */
|
|
298
|
+
.text-field__suggestion-label {
|
|
299
|
+
flex: 1;
|
|
300
|
+
min-width: 0;
|
|
301
|
+
overflow: hidden;
|
|
302
|
+
text-overflow: ellipsis;
|
|
303
|
+
white-space: nowrap;
|
|
304
|
+
}
|
|
305
|
+
.text-field__suggestion-hint {
|
|
306
|
+
color: var(--color-lightGray);
|
|
307
|
+
flex-shrink: 0;
|
|
308
|
+
margin-left: 8px;
|
|
309
|
+
white-space: nowrap;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* **들리기만 하는 칸** — 고를 것이 없다는 말이 여기로 간다.
|
|
313
|
+
늘 서 있고 글만 드나든다(사는 영역을 세웠다 치웠다 하면 낭독기가 첫 글을 놓친다). */
|
|
314
|
+
.text-field__status {
|
|
315
|
+
position: absolute;
|
|
316
|
+
width: 1px;
|
|
317
|
+
height: 1px;
|
|
318
|
+
margin: -1px;
|
|
319
|
+
padding: 0;
|
|
320
|
+
overflow: hidden;
|
|
321
|
+
clip: rect(0 0 0 0);
|
|
322
|
+
white-space: nowrap;
|
|
323
|
+
border: 0;
|
|
324
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Ref, type FocusEvent, type KeyboardEvent } from 'react';
|
|
2
|
+
import { type TextFieldSuggestion } from './useComboboxSuggestions';
|
|
2
3
|
import './TextField.css';
|
|
3
4
|
/** @public */
|
|
4
5
|
export interface TextFieldProps {
|
|
@@ -99,7 +100,16 @@ export interface TextFieldProps {
|
|
|
99
100
|
* 자동완성 후보 목록 — 지정하면 콤보박스(입력 중 후보 드롭다운 + 키보드 네비게이션)로 동작한다.
|
|
100
101
|
* 필터링은 현재 값 기준으로 동작하므로 controlled(value+onChange) 사용을 전제로 한다.
|
|
101
102
|
*/
|
|
102
|
-
|
|
103
|
+
/** 자동완성 후보. 글자 묶음, 또는 `{ id, label, hint? }` 줄 묶음(말귀 요청 ㊻). */
|
|
104
|
+
suggestions?: (string | TextFieldSuggestion)[];
|
|
105
|
+
/** 거르는 법. `false` 면 **안 거른다**(부르는 쪽이 이미 걸러 준 것). 기본은 `label` 에
|
|
106
|
+
* 글자가 들어 있나. 초성 찾기처럼 셈이 다른 자리에서 바꾼다. */
|
|
107
|
+
filterSuggestions?: false | ((suggestions: (string | TextFieldSuggestion)[], value: string) => (string | TextFieldSuggestion)[]);
|
|
108
|
+
/** 주면 **고를 때 칸에 값을 안 넣고** 고른 줄을 그대로 준다 — '값 채우기' 가 아니라
|
|
109
|
+
* '어디로 가기' 인 자리에 쓴다. */
|
|
110
|
+
onSelectSuggestion?: (item: string | TextFieldSuggestion) => void;
|
|
111
|
+
/** 목록이 뜨면 첫 줄을 바로 짚는다. 기본 거짓 — `useComboboxSuggestions` 의 같은 이름 참고. */
|
|
112
|
+
autoActiveFirst?: boolean;
|
|
103
113
|
/** 포커스 시 입력이 비어 있어도 전체 후보 목록을 표시 (suggestions와 함께 사용) */
|
|
104
114
|
openOnFocus?: boolean;
|
|
105
115
|
}
|
|
@@ -13,7 +13,7 @@ import { useIsoLayoutEffect } from '../hooks/useIsoLayoutEffect';
|
|
|
13
13
|
import './TextField.css';
|
|
14
14
|
/** @public */
|
|
15
15
|
export default function TextField(props) {
|
|
16
|
-
const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, ghostText, ghostHint, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions: suggestionsProp, openOnFocus = false, } = props;
|
|
16
|
+
const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, ghostText, ghostHint, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions: suggestionsProp, filterSuggestions, onSelectSuggestion, autoActiveFirst, openOnFocus = false, } = props;
|
|
17
17
|
// 읽기 전용·꺼진 칸은 후보로 값을 바꾸지 못한다 — 전에는 ↓·Enter·후보 누름으로 onChange 가 나갔다
|
|
18
18
|
const suggestions = readOnly || disabled ? undefined : suggestionsProp;
|
|
19
19
|
const { t } = useTranslation();
|
|
@@ -41,6 +41,9 @@ export default function TextField(props) {
|
|
|
41
41
|
onChange,
|
|
42
42
|
suggestions: suggestions ?? [],
|
|
43
43
|
openOnFocus,
|
|
44
|
+
filterSuggestions,
|
|
45
|
+
onSelectSuggestion,
|
|
46
|
+
autoActiveFirst,
|
|
44
47
|
});
|
|
45
48
|
/* 후보 목록이 지금 DOM 에 있는가 — 아래 렌더 조건과 **같은 식**이어야 한다 */
|
|
46
49
|
const 목록떴나 = !!suggestions && combobox.open && combobox.filtered.length > 0;
|
|
@@ -266,10 +269,12 @@ export default function TextField(props) {
|
|
|
266
269
|
setInnerLen(0);
|
|
267
270
|
}
|
|
268
271
|
onChange?.('');
|
|
269
|
-
}, tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }),
|
|
272
|
+
}, tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }), !!suggestions && (_jsx("span", { className: "text-field__status", role: "status", "aria-live": "polite", children: combobox.open && (value ?? '').length > 0 && combobox.filtered.length === 0
|
|
273
|
+
? t('common.noMatches', '맞는 항목이 없습니다')
|
|
274
|
+
: '' })), 목록떴나 && (typeof document === 'undefined' ? null : createPortal(_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", style: listStyle ?? { position: 'fixed', visibility: 'hidden' }, "data-tooltip-ignore": true, children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
|
|
270
275
|
// 선택은 click 에서 한다. onMouseDown 만 두면 마우스로는 눌리지만 스크린리더가
|
|
271
276
|
// 이 option 을 활성화할 때는 click 만 발생해 아무 일도 일어나지 않는다.
|
|
272
277
|
// mousedown 에서는 기본 동작(포커스 이동 → 입력 blur)을 막아, click 이 도달하기
|
|
273
278
|
// 전에 목록이 닫히지 않게 한다 — blur 쪽 120ms 딜레이에 기대지 않아도 된다.
|
|
274
|
-
onMouseDown: e => e.preventDefault(), onClick: () => { combobox.handleSelect(s); autoRef.current?.focus(); }, children: s }, `${s}-${i}`))) }), document.body)), (helperText || error) && (_jsx("div", { className: "text-field__footer", children: _jsx("span", { id: helperId, className: "text-field__helper caption-1", role: error ? 'alert' : undefined, children: error || helperText }) }))] }));
|
|
279
|
+
onMouseDown: e => e.preventDefault(), onClick: () => { combobox.handleSelect(s); autoRef.current?.focus(); }, children: typeof s === 'string' ? s : (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-field__suggestion-label", children: s.label }), s.hint && _jsx("span", { className: "text-field__suggestion-hint caption-1", children: s.hint })] })) }, typeof s === 'string' ? `${s}-${i}` : s.id))) }), document.body)), (helperText || error) && (_jsx("div", { className: "text-field__footer", children: _jsx("span", { id: helperId, className: "text-field__helper caption-1", role: error ? 'alert' : undefined, children: error || helperText }) }))] }));
|
|
275
280
|
}
|
|
@@ -27,5 +27,5 @@ export default function WordTableButton({ items, name, description, createdAt, c
|
|
|
27
27
|
return (_jsxs("div", { className: cx('word-table-button', selected ? 'word-table-button--selected' : '', !hasStoreFooter ? 'word-table-button--no-footer' : '', className), role: "button", tabIndex: 0, "aria-label": code ? `${name} (${code})` : name, onClick: renaming ? undefined : onClick, onKeyDown: handleKey, children: [isNew && _jsx("span", { className: "word-table-button__new-dot", "aria-label": t('myWordTable.newBadge', '새로 만든 표') }), _jsx(WordTableThumbnail, { items: items, className: "word-table-button__thumb" }), _jsxs("div", { className: "word-table-button__info", children: [_jsxs("div", { className: "word-table-button__info-text", children: [titleNode ?? _jsx("div", { className: "word-table-button__title h4", children: name }), description && _jsx("div", { className: "word-table-button__desc body-3", children: description })] }), onMore && !renaming && (_jsx(Button, { htmlType: "button", className: "word-table-button__more", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: "ellipsis",
|
|
28
28
|
/* 하드코딩 '더보기'였다(영어 화면에서도 한국어로 남았다). 목록에 표가
|
|
29
29
|
여러 개면 이름이 같은 버튼이 여러 개이기도 해서 이름을 함께 넣는다. */
|
|
30
|
-
ariaLabel: t('common.moreFor', { name }), onClick: (e) => { e.stopPropagation(); onMore(e); } }))] }), (code || createdAt || published || shared) && (_jsxs("div", { className: "word-table-button__meta-row", children: [_jsx("span", { className: "word-table-button__meta-left", children: _jsx("span", { className: "word-table-button__meta caption-1", children: createdAt ? formatRelativeTime(createdAt, t) : '' }) }), _jsxs("span", { className: "word-table-button__meta-right", children: [(published || shared) && (_jsxs("span", { className: "word-table-button__badges", children: [published && (_jsx(Badge, { className: "word-table-button__badge", size: "small", variant: "main", fill: "soft", iconLeft: "book-marked", label: publishedLabel ?? t('myWordTable.publishedBadge', '발행됨') })), shared && (_jsx(Badge, { className: "word-table-button__badge", size: "small", fill: "soft", iconLeft: "link", label: sharedLabel ?? t('myWordTable.sharedBadge', '공유 중') }))] })), code && _jsx("span", { className: "word-table-button__code caption-1", children: code })] })] })), hasStoreFooter && (_jsxs("div", { className: "word-table-button__footer", children: [_jsxs("span", { className: "word-table-button__store-by", children: [publisherName && (_jsx("button", { className: "training-card__publisher body-4", type: "button", onClick: (e) => { e.stopPropagation(); onPublisherClick?.(); }, children: publisherName })), publishedAt && _jsx("span", { className: "training-card__date body-4", children: formatRelativeTime(publishedAt, t) })] }), _jsxs("span", { className: "word-table-button__store-stats", children: [likeCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "like", iconLeft: "heart", label: String(likeCount) }), downloadCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "main", iconLeft: "download", label: String(downloadCount) })] })] }))] }));
|
|
30
|
+
ariaLabel: t('common.moreFor', { name }), onClick: (e) => { e.stopPropagation(); onMore(e); } }))] }), (code || createdAt || published || shared) && (_jsxs("div", { className: "word-table-button__meta-row", children: [_jsx("span", { className: "word-table-button__meta-left", children: _jsx("span", { className: "word-table-button__meta caption-1", children: createdAt ? formatRelativeTime(createdAt, t) : '' }) }), _jsxs("span", { className: "word-table-button__meta-right", children: [(published || shared) && (_jsxs("span", { className: "word-table-button__badges", children: [published && (_jsx(Badge, { className: "word-table-button__badge", size: "small", variant: "main", fill: "soft", iconLeft: "book-marked", label: publishedLabel ?? t('myWordTable.publishedBadge', '발행됨') })), shared && (_jsx(Badge, { className: "word-table-button__badge", size: "small", fill: "soft", iconLeft: "link", label: sharedLabel ?? t('myWordTable.sharedBadge', '공유 중') }))] })), code && _jsx("span", { className: "word-table-button__code caption-1", children: code })] })] })), hasStoreFooter && (_jsxs("div", { className: "word-table-button__footer", children: [_jsxs("span", { className: "word-table-button__store-by", children: [publisherName && (_jsx("button", { className: "training-card__publisher body-4", type: "button", onClick: (e) => { e.stopPropagation(); onPublisherClick?.(); }, children: publisherName })), publishedAt && _jsx("span", { className: "training-card__date body-4", children: formatRelativeTime(publishedAt, t) })] }), _jsxs("span", { className: "word-table-button__store-stats", children: [likeCount != null && (_jsx(Badge, { size: "small", fill: "soft", variant: "like", iconLeft: "heart", label: String(likeCount), "aria-label": t('common.likeCount', { count: likeCount, defaultValue: `좋아요 ${likeCount}` }) })), downloadCount != null && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", iconLeft: "download", label: String(downloadCount), "aria-label": t('common.downloadCount', { count: downloadCount, defaultValue: `가져간 수 ${downloadCount}` }) }))] })] }))] }));
|
|
31
31
|
}
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { type KeyboardEvent } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* 후보 한 줄 — 글자 하나로는 못 담는 자리(말귀 요청 ㊻, 2026-10-01).
|
|
4
|
+
*
|
|
5
|
+
* `label` 이 줄에 적히고 **거르기의 기본 대상**이며, `hint` 는 그 오른쪽에 흐리게 서는
|
|
6
|
+
* 갈래 글자(`숙제` · `나의 단어표` 같은 것)다. `id` 는 고른 뒤 부르는 쪽이 무엇을 골랐는지
|
|
7
|
+
* 알아보는 열쇠 — 이름이 같은 두 줄을 가릴 수 있어야 한다.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export interface TextFieldSuggestion {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
}
|
|
2
15
|
/**
|
|
3
16
|
* TextField의 `suggestions` 옵션이 쓰는 자동완성 콤보박스 상태/키보드 로직.
|
|
4
17
|
* 순수 입력(값 관리)과 분리해서, 자동완성이 필요 없는 TextField 사용처는
|
|
@@ -8,21 +21,39 @@ import { type KeyboardEvent } from 'react';
|
|
|
8
21
|
* `handleCompositionEnd` 를 걸어야 한다(TextField 는 건다). 걸지 않으면 지금처럼 매 값마다 다시 거른다.
|
|
9
22
|
* @public
|
|
10
23
|
*/
|
|
11
|
-
export declare function useComboboxSuggestions({ value, onChange, suggestions, openOnFocus, }: {
|
|
24
|
+
export declare function useComboboxSuggestions<T extends string | TextFieldSuggestion = string>({ value, onChange, suggestions, openOnFocus, filterSuggestions, onSelectSuggestion, autoActiveFirst, }: {
|
|
12
25
|
value: string;
|
|
13
26
|
onChange?: (value: string) => void;
|
|
14
|
-
|
|
27
|
+
/** 글자 묶음, 또는 `{ id, label, hint? }` 줄 묶음 */
|
|
28
|
+
suggestions: T[];
|
|
15
29
|
openOnFocus?: boolean;
|
|
30
|
+
/** 거르는 법을 바꾼다. `false` 면 **안 거른다** — 부르는 쪽이 이미 걸러 준 것으로 본다.
|
|
31
|
+
* 기본은 `label` 에 글자가 들어 있나(대소문자 무시)다. 초성 찾기처럼 셈이 다른 자리에서
|
|
32
|
+
* 쓴다(`matchesChoseongSearch` 는 이 꾸러미의 `utils` 에 있다). */
|
|
33
|
+
filterSuggestions?: false | ((suggestions: T[], value: string) => T[]);
|
|
34
|
+
/** 주면 **고를 때 칸에 값을 안 넣는다** — 고른 줄을 그대로 받는다. 고르는 것이 '값 채우기'
|
|
35
|
+
* 가 아니라 '어디로 가기' 인 자리(화면 찾기 등)에 쓴다. */
|
|
36
|
+
onSelectSuggestion?: (item: T) => void;
|
|
37
|
+
/** 목록이 뜨면 **첫 줄을 바로 짚는다**(ARIA 의 automatic selection).
|
|
38
|
+
*
|
|
39
|
+
* 기본은 거짓 — 아무것도 안 짚은 채 열리고, 그 상태의 Enter 는 제안을 안 고른다
|
|
40
|
+
* (친 글자를 그대로 쓰겠다는 뜻이다). 자유롭게 치는 칸에서는 그쪽이 맞다.
|
|
41
|
+
* **고르는 것이 '어디로 가기' 인 칸에서는 참이 맞다** — 치자마자 Enter 로 첫 줄에 가고,
|
|
42
|
+
* `aria-expanded` 가 참인데 짚은 줄이 없어 낭독기에 "목록이 떴다" 만 가고 **뭐가 떴는지는
|
|
43
|
+
* 안 가는** 것도 함께 풀린다(말귀가 그래서 손수 0 으로 시작하게 두고 있었다).
|
|
44
|
+
* `onSelectSuggestion` 과 **묶지 않았다** — 고르는 것이 가기인데도 친 글자를 그대로
|
|
45
|
+
* 쓰고 싶은 칸이 있다(검색어를 그대로 보내는 자리). 서로 다른 결정이다. */
|
|
46
|
+
autoActiveFirst?: boolean;
|
|
16
47
|
}): {
|
|
17
48
|
listId: string;
|
|
18
49
|
open: boolean;
|
|
19
50
|
activeIdx: number;
|
|
20
|
-
filtered:
|
|
51
|
+
filtered: T[];
|
|
21
52
|
handleFocus: () => void;
|
|
22
53
|
handleBlur: () => void;
|
|
23
54
|
handleInput: () => void;
|
|
24
55
|
handleKeyDown: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
25
|
-
handleSelect: (item:
|
|
56
|
+
handleSelect: (item: T) => void;
|
|
26
57
|
/** 입력칸의 onCompositionStart 에 건다 — 조합 중에는 걸리는 후보가 0 건이 되어도 직전 목록을 둔다 */
|
|
27
58
|
handleCompositionStart: () => void;
|
|
28
59
|
/** 입력칸의 onCompositionEnd 에 건다 — 조합이 끝나면 목록을 다시 계산한다 */
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
|
+
/** 글자 하나든 한 줄이든 **줄에 적히는 말**을 꺼낸다 */
|
|
4
|
+
function 라벨(x) {
|
|
5
|
+
return typeof x === 'string' ? x : x.label;
|
|
6
|
+
}
|
|
3
7
|
/**
|
|
4
8
|
* TextField의 `suggestions` 옵션이 쓰는 자동완성 콤보박스 상태/키보드 로직.
|
|
5
9
|
* 순수 입력(값 관리)과 분리해서, 자동완성이 필요 없는 TextField 사용처는
|
|
@@ -9,7 +13,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
|
9
13
|
* `handleCompositionEnd` 를 걸어야 한다(TextField 는 건다). 걸지 않으면 지금처럼 매 값마다 다시 거른다.
|
|
10
14
|
* @public
|
|
11
15
|
*/
|
|
12
|
-
export function useComboboxSuggestions({ value, onChange, suggestions, openOnFocus = false, }) {
|
|
16
|
+
export function useComboboxSuggestions({ value, onChange, suggestions, openOnFocus = false, filterSuggestions, onSelectSuggestion, autoActiveFirst = false, }) {
|
|
13
17
|
const listId = useId();
|
|
14
18
|
const [open, setOpen] = useState(false);
|
|
15
19
|
const [activeIdx, setActiveIdx] = useState(-1);
|
|
@@ -18,10 +22,16 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
18
22
|
if (!suggestions.length)
|
|
19
23
|
return [];
|
|
20
24
|
const v = value ?? '';
|
|
25
|
+
/* **안 거르는 쪽**(`false`)은 빈 값에서도 그대로 낸다 — 부르는 쪽이 이미 고른 것이라
|
|
26
|
+
여기서 또 비우면 그 셈을 버리는 꼴이다. */
|
|
27
|
+
if (filterSuggestions === false)
|
|
28
|
+
return suggestions;
|
|
21
29
|
if (!v)
|
|
22
30
|
return openOnFocus ? suggestions : [];
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
if (typeof filterSuggestions === 'function')
|
|
32
|
+
return filterSuggestions(suggestions, v);
|
|
33
|
+
return suggestions.filter(s => 라벨(s).toLowerCase().includes(v.toLowerCase()));
|
|
34
|
+
}, [suggestions, value, openOnFocus, filterSuggestions]);
|
|
25
35
|
/* **한글 조합 중에는 0 건이 되어도 직전 목록을 둔다.** '사'→'삭'→'사고' 로 치는 동안 중간값 '삭' 에 걸리는 후보가 없어
|
|
26
36
|
목록이 한 박자 사라졌다 돌아왔다(깜빡임·스크린리더가 목록 닫힘을 알림). 조합이 끝나면(compositionend) 다시 계산한다.
|
|
27
37
|
부르는 쪽이 suggestions 를 값으로 미리 거른 경우(matchesWordQuery 순위 등)도 같다 — 순위 규칙은 건드리지 않는다. */
|
|
@@ -34,21 +44,26 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
34
44
|
const handleCompositionEnd = useCallback(() => set조합중(false), []);
|
|
35
45
|
// 메뉴가 닫히면 활성 인덱스를 리셋하는 의도된 동기화다
|
|
36
46
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
47
|
+
const 처음짚는자리 = autoActiveFirst ? 0 : -1;
|
|
37
48
|
useEffect(() => { if (!open)
|
|
38
|
-
setActiveIdx(
|
|
49
|
+
setActiveIdx(처음짚는자리); }, [open, 처음짚는자리]);
|
|
39
50
|
useEffect(() => () => { if (blurTimerRef.current)
|
|
40
51
|
clearTimeout(blurTimerRef.current); }, []);
|
|
41
52
|
/* 입력으로 후보가 줄면 가리키던 번호가 목록 밖에 남는다 — 그 채로 Enter 를 누르면 filtered[3] 같은
|
|
42
53
|
없는 칸을 골라 onChange(undefined) 가 나갔다. 밖이면 '안 가리킴' 으로 본다. */
|
|
43
|
-
const safeIdx = activeIdx < filtered.length ? activeIdx : -1;
|
|
54
|
+
const safeIdx = activeIdx < filtered.length ? activeIdx : 처음짚는자리 < filtered.length ? 처음짚는자리 : -1;
|
|
44
55
|
const handleSelect = useCallback((item) => {
|
|
45
|
-
|
|
56
|
+
/* 고르는 것이 '값 채우기' 가 아닌 자리가 있다 — 그때는 칸을 안 건드린다 */
|
|
57
|
+
if (onSelectSuggestion)
|
|
58
|
+
onSelectSuggestion(item);
|
|
59
|
+
else
|
|
60
|
+
onChange?.(라벨(item));
|
|
46
61
|
setOpen(false);
|
|
47
|
-
}, [onChange]);
|
|
62
|
+
}, [onChange, onSelectSuggestion]);
|
|
48
63
|
const handleFocus = useCallback(() => setOpen(true), []);
|
|
49
64
|
/* 글자를 치면 목록을 다시 연다 — 고르거나 Esc 로 닫은 뒤에는 포커스를 뺐다 넣어야만 열렸다.
|
|
50
65
|
새 글자에는 새 후보가 서므로 가리킴도 처음으로 돌린다. */
|
|
51
|
-
const handleInput = useCallback(() => { setOpen(true); setActiveIdx(
|
|
66
|
+
const handleInput = useCallback(() => { setOpen(true); setActiveIdx(처음짚는자리); }, [처음짚는자리]);
|
|
52
67
|
// blur가 리스트 항목의 onMouseDown보다 먼저 발생하는 걸 막기 위한 딜레이
|
|
53
68
|
const handleBlur = useCallback(() => {
|
|
54
69
|
blurTimerRef.current = setTimeout(() => setOpen(false), 120);
|
|
@@ -61,11 +76,12 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
61
76
|
if (e.key === 'ArrowDown') {
|
|
62
77
|
e.preventDefault();
|
|
63
78
|
setOpen(true);
|
|
64
|
-
setActiveIdx(i => Math.min(filtered.length - 1, (i < filtered.length ? i :
|
|
79
|
+
setActiveIdx(i => Math.min(filtered.length - 1, (i < filtered.length ? i : 처음짚는자리) + 1));
|
|
65
80
|
}
|
|
66
81
|
else if (e.key === 'ArrowUp') {
|
|
67
82
|
e.preventDefault();
|
|
68
|
-
|
|
83
|
+
/* 첫 줄을 늘 짚는 칸에서는 **안 짚은 상태로 못 올라간다** — 그 상태가 없다 */
|
|
84
|
+
setActiveIdx(i => Math.max(처음짚는자리, (i < filtered.length ? i : filtered.length) - 1));
|
|
69
85
|
}
|
|
70
86
|
else if (e.key === 'Enter') {
|
|
71
87
|
// 한글 조합을 끝내는 Enter는 선택이 아니다 — 조합 중엔 제안을 고르지 않는다
|
|
@@ -78,7 +94,7 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
78
94
|
setOpen(false);
|
|
79
95
|
} // 제안이 열렸을 때만 소비(상위 팝업 ESC보다 우선하지 않도록)
|
|
80
96
|
}
|
|
81
|
-
}, [filtered, safeIdx, open, handleSelect]);
|
|
97
|
+
}, [filtered, safeIdx, open, handleSelect, 처음짚는자리]);
|
|
82
98
|
return {
|
|
83
99
|
listId, open, activeIdx: safeIdx, filtered, handleFocus, handleBlur, handleInput, handleKeyDown, handleSelect,
|
|
84
100
|
/** 입력칸의 onCompositionStart 에 건다 — 조합 중에는 걸리는 후보가 0 건이 되어도 직전 목록을 둔다 */
|
|
@@ -123,7 +123,7 @@ export { default as Loader } from './components/Loader';
|
|
|
123
123
|
export { default as SegmentedControl } from './components/SegmentedControl';
|
|
124
124
|
export { default as TextField } from './components/TextField';
|
|
125
125
|
export type { TextFieldProps } from './components/TextField';
|
|
126
|
-
export { useComboboxSuggestions } from './components/useComboboxSuggestions';
|
|
126
|
+
export { useComboboxSuggestions, type TextFieldSuggestion } from './components/useComboboxSuggestions';
|
|
127
127
|
export { default as Dropdown } from './components/Dropdown';
|
|
128
128
|
export type { DropdownProps, DropdownOption } from './components/Dropdown';
|
|
129
129
|
export { useAnchoredMenu } from './hooks/useAnchoredMenu';
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"reset": "Reset",
|
|
28
28
|
"update": "Update",
|
|
29
29
|
"download": "Download",
|
|
30
|
+
"likeCount": "{{count}} likes",
|
|
31
|
+
"selectedOfMax": "{{count}} of {{max}}",
|
|
32
|
+
"downloadCount": "{{count}} downloads",
|
|
30
33
|
"search": "Search",
|
|
31
34
|
"empty": "No data.",
|
|
32
35
|
"yes": "Yes",
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
"continue": "Continue",
|
|
50
53
|
"notification": "Notice",
|
|
51
54
|
"emptyList": "No items",
|
|
55
|
+
"noMatches": "No matching items",
|
|
52
56
|
"menu": "Menu",
|
|
53
57
|
"resetSelection": "Reset Selection",
|
|
54
58
|
"pageSizeLabel": "Show {{n}}",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"statusFilter": "상태 필터",
|
|
4
4
|
"pageSizeSelect": "페이지당 표시 개수",
|
|
5
5
|
"selectedCount": "{{count}}개 선택됨",
|
|
6
|
+
"selectedOfMax": "최대 {{max}}개 중 {{count}}개",
|
|
6
7
|
"cancel": "취소",
|
|
7
8
|
"info": "설명",
|
|
8
9
|
"errorState": {
|
|
@@ -25,6 +26,8 @@
|
|
|
25
26
|
"reset": "초기화",
|
|
26
27
|
"update": "업데이트",
|
|
27
28
|
"download": "다운로드",
|
|
29
|
+
"likeCount": "좋아요 {{count}}",
|
|
30
|
+
"downloadCount": "가져간 수 {{count}}",
|
|
28
31
|
"search": "검색",
|
|
29
32
|
"empty": "데이터가 없습니다.",
|
|
30
33
|
"yes": "예",
|
|
@@ -48,6 +51,7 @@
|
|
|
48
51
|
"continue": "계속",
|
|
49
52
|
"notification": "알림",
|
|
50
53
|
"emptyList": "항목이 없습니다.",
|
|
54
|
+
"noMatches": "맞는 항목이 없습니다",
|
|
51
55
|
"menu": "메뉴",
|
|
52
56
|
"resetSelection": "선택 초기화",
|
|
53
57
|
"pageSizeLabel": "{{n}}개씩 보기",
|