@ihabdevteam/core 0.142.0 → 0.143.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +61 -21
  7. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  8. package/dist/packages/core/src/components/BarChart.js +4 -3
  9. package/dist/packages/core/src/components/BottomNav.js +4 -0
  10. package/dist/packages/core/src/components/Button.js +10 -4
  11. package/dist/packages/core/src/components/DatePicker.js +5 -1
  12. package/dist/packages/core/src/components/Dialog.js +3 -1
  13. package/dist/packages/core/src/components/Dropdown.js +6 -2
  14. package/dist/packages/core/src/components/FileUpload.js +3 -1
  15. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  16. package/dist/packages/core/src/components/PanelRow.js +5 -0
  17. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  18. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  19. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  20. package/dist/packages/core/src/components/Popup/Popup.js +5 -2
  21. package/dist/packages/core/src/components/TextField.js +3 -1
  22. package/dist/packages/core/src/components/Tooltip/Tooltip.js +1 -1
  23. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  24. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
  25. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +48 -1
  26. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  27. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  28. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  29. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  30. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  31. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  32. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  33. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  34. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  35. package/dist/packages/core/src/components/barChartData.js +7 -3
  36. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  37. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  38. package/dist/packages/core/src/components.d.ts +1 -0
  39. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  40. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  41. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  42. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  43. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
  44. package/dist/packages/core/src/utils/date.d.ts +18 -0
  45. package/dist/packages/core/src/utils/date.js +23 -0
  46. package/dist/packages/core/src/utils/format.js +2 -1
  47. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  48. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  49. package/dist/packages/core/src/utils/josa.js +2 -0
  50. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  51. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  52. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  53. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  54. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  55. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  56. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  57. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  58. package/dist/packages/core/src/utils.d.ts +1 -1
  59. package/dist/packages/core/src/utils.js +1 -1
  60. package/dist/packages/core/src/version.d.ts +1 -1
  61. package/dist/packages/core/src/version.js +1 -1
  62. package/dist/utils.cjs.js +2 -2
  63. package/package.json +1 -1
@@ -293,7 +293,7 @@ export declare type BadgeType = 'bar' | 'dot';
293
293
  /**
294
294
  * 막대차트 섹션 — 제목 + 구간 네비게이션(이전/다음 + 드롭다운) + 월/주 토글 + 2계열 막대차트.
295
295
  * - 월(month): 월 드롭다운 + 주별 막대(1주~5주)
296
- * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(일~토) 막대
296
+ * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
297
297
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
298
298
  */
299
299
  export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, inGrid, }: BarChartProps): JSX.Element;
@@ -395,10 +395,6 @@ export declare interface BarChartProps {
395
395
  /** 시맨틱 사이즈 — 차트 캔버스 높이·막대 너비 프리셋 */
396
396
  export declare type BarChartSize = 'small' | 'medium' | 'large';
397
397
 
398
- /**
399
- * barChartData.ts
400
- * BarChart의 순수 데이터 계층 — 날짜→월/주/일 막대 데이터 집계 (React 무관).
401
- */
402
398
  /** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
403
399
  * parts: 다계열 stacked용 계열별 값(있으면 a는 합계로 사용). */
404
400
  export declare interface BarData {
@@ -906,7 +902,9 @@ export declare interface ChartLegendItem {
906
902
  /** 글자→발음: 자모를 해당 발음 버튼 값으로. 대응 버튼이 없으면 null(선택 해제). */
907
903
  export declare function charToPronValue(part: CompositionPart, jamo: string): string | null;
908
904
 
909
- /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계) */
905
+ /** 날짜별 카운트 포인트 — a/b 2계열 또는 parts(다계열 stacked, a는 합계).
906
+ * `date` 는 **KST 달력 날짜를 기기 달력 자정 Date 로** 옮긴 것: 시각이면 `parseLocalYmd(toKstYmd(createdAt))`,
907
+ * 서버가 KST 로 집계한 'YYYY-MM-DD' 면 `parseLocalYmd(log_date)`. */
910
908
  export declare interface ChartPoint {
911
909
  date: Date;
912
910
  a: number;
@@ -1314,6 +1312,18 @@ export declare interface ContrastFilterPanelProps {
1314
1312
  className?: string;
1315
1313
  }
1316
1314
 
1315
+ /** @public getControlDateRange 의 결과 — 기간의 첫날 00:00 KST ~ 끝날 23:59:59.999 KST 순간과, 그 두 날의 KST 달력 날짜 */
1316
+ export declare interface ControlDateRange {
1317
+ /** 첫날 00:00:00.000 KST 순간 — 서버에 물을 때 `from.toISOString()` */
1318
+ from: Date;
1319
+ /** 끝날 23:59:59.999 KST 순간 */
1320
+ to: Date;
1321
+ /** 첫날 KST 'YYYY-MM-DD' — 이름표·달력 셈은 `parseLocalYmd(fromYmd)` 로(순간 `from` 을 기기 getter 로 읽으면 해외에서 하루 어긋난다) */
1322
+ fromYmd: string;
1323
+ /** 끝날 KST 'YYYY-MM-DD' */
1324
+ toYmd: string;
1325
+ }
1326
+
1317
1327
  /** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
1318
1328
  export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
1319
1329
 
@@ -1376,7 +1386,7 @@ export declare function copyText(text: string): Promise<boolean>;
1376
1386
  *
1377
1387
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1378
1388
  */
1379
- export declare const CORE_VERSION = "0.142.0";
1389
+ export declare const CORE_VERSION = "0.143.0";
1380
1390
 
1381
1391
  /** @public */
1382
1392
  declare const coreDefault: Record<string, unknown>;
@@ -2267,11 +2277,13 @@ export declare interface FormatYmdOptions {
2267
2277
  /** @public */
2268
2278
  export declare function getAssetBaseUrl(): string;
2269
2279
 
2270
- /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
2271
- export declare function getControlDateRange(control: BarChartControl): {
2272
- from: Date;
2273
- to: Date;
2274
- };
2280
+ /**
2281
+ * 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
2282
+ *
2283
+ * @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
2284
+ * @public
2285
+ */
2286
+ export declare function getControlDateRange(control: BarChartControl, offset?: number): ControlDateRange;
2275
2287
 
2276
2288
  /** @public */
2277
2289
  export declare function getDailySelection(): {
@@ -2383,6 +2395,14 @@ export declare function getSteppedPayload(pageAnswers: unknown, itemIndex: numbe
2383
2395
  /** KST(Asia/Seoul) 기준 오늘 날짜를 'YYYY-MM-DD'로 반환. DB 날짜 비교 기준선으로 사용. */
2384
2396
  export declare function getTodayKst(): string;
2385
2397
 
2398
+ /**
2399
+ * KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
2400
+ * '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
2401
+ *
2402
+ * @public
2403
+ */
2404
+ export declare function getTodayKstDate(): Date;
2405
+
2386
2406
  /**
2387
2407
  * 등록된 주제 카테고리 목록을 반환합니다.
2388
2408
  * configureTrainingCategories() 미호출 시 기본값을 반환합니다.
@@ -2647,6 +2667,14 @@ export declare const JONGSEONG: readonly ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ",
2647
2667
 
2648
2668
  export declare const JUNGSEONG: readonly ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"];
2649
2669
 
2670
+ /**
2671
+ * KST 달력 날짜(연, 0부터 세는 월, 일)의 **00:00 KST 순간.** 일·월은 넘쳐도 된다(`Date.UTC` 처럼 — 32일은 다음 달 1일).
2672
+ * 서버에 기간을 물을 때(`toISOString()`) 기기 시간대가 아니라 KST 의 하루로 자르려고 쓴다.
2673
+ *
2674
+ * @public
2675
+ */
2676
+ export declare function kstStartOfDay(year: number, month0: number, day: number): Date;
2677
+
2650
2678
  /**
2651
2679
  * 칸 번호 → 그때 보인 낱말. 채점과 **함께 저장한다.**
2652
2680
  *
@@ -3543,6 +3571,18 @@ export declare type PanelRowToggle = Base & {
3543
3571
 
3544
3572
  declare type PanelSize = 'small' | 'medium' | 'large';
3545
3573
 
3574
+ /**
3575
+ * 'YYYY-MM-DD' 날짜 전용 문자열(예: 서버가 KST 기준으로 집계해 내려주는 log_date)을
3576
+ * 로컬 타임존 자정 Date로 파싱한다. new Date('YYYY-MM-DD')는 UTC 자정으로 해석되어,
3577
+ * UTC보다 서쪽 시간대(예: 미국)에서 getDate()/getMonth() 같은 로컬 getter로 읽으면
3578
+ * 하루 전으로 밀린다 — 차트를 날짜별로 버킷팅할 때 특히 문제가 된다.
3579
+ *
3580
+ * 시각(ISO)에서 KST 의 날을 달력 Date 로 얻으려면 `parseLocalYmd(toKstYmd(시각))`.
3581
+ *
3582
+ * @public
3583
+ */
3584
+ export declare function parseLocalYmd(ymd: string): Date;
3585
+
3546
3586
  /**
3547
3587
  * 드롭다운 값을 정렬 상태로 되돌린다.
3548
3588
  *
@@ -4113,7 +4153,7 @@ export declare type PopupOptions = {
4113
4153
  /** @public */
4114
4154
  export declare interface PopupProps {
4115
4155
  title?: ReactNode;
4116
- /** 제목 아래 보조 설명(부제). title이 있을 때만 렌더된다. */
4156
+ /** 제목 아래 보조 설명(부제). 제목 줄이 그려질 때(useTitle 이고 title·headerActions·onClose 중 하나가 있을 때) 보인다. */
4117
4157
  subtitle?: ReactNode;
4118
4158
  headerActions?: ReactNode;
4119
4159
  children?: ReactNode | ((opts: PopupOptions) => ReactNode);
@@ -7296,7 +7336,7 @@ export declare type TrainingPagePopupProps = {
7296
7336
  * 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
7297
7337
  */
7298
7338
  noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
7299
- /** 훈련 결과 팝업 렌더러 — 없으면 결과 팝업을 표시하지 않음 */
7339
+ /** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
7300
7340
  renderResultPopup?: (props: {
7301
7341
  score?: number | null;
7302
7342
  results?: unknown[];
@@ -8715,9 +8755,9 @@ export declare interface WordFilterSnapshot {
8715
8755
  sameStructureOnly: boolean;
8716
8756
  phonemeView: 'char' | 'pron' | 'direct';
8717
8757
  gradeFilter: string[];
8718
- /** 품사 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
8758
+ /** 품사 필터 — 고정 코드(noun·verb·modifier). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
8719
8759
  posFilter?: string[];
8720
- /** 어종 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
8760
+ /** 어종 필터 — 고정 코드(native·sino·foreign·mixed). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
8721
8761
  wordTypeFilter?: string[];
8722
8762
  }
8723
8763
 
@@ -8900,7 +8940,7 @@ export declare interface WordTableBlockProps {
8900
8940
  tableName?: string;
8901
8941
  /** 전달하면 외부 데이터 표시 모드, 없으면 편집 모드 */
8902
8942
  items?: WordSearchResultItem[];
8903
- /** 전달하면 저장 팝업을 내부에서 열고 확인 후 name·items·description을 전달.
8943
+ /** 전달하면 저장하기가 저장 팝업(`renderSavePopup` 이 그림 — 없으면 아무것도 뜨지 않는다)을 열고 확인 후 name·items·description을 전달.
8904
8944
  * saveAsNew=true면 '다른 이름으로 저장'(수정 대신 새 표 생성)을 뜻한다. */
8905
8945
  onSaveComplete?: (name: string, items: WordSearchResultItem[], description?: string, saveAsNew?: boolean) => void;
8906
8946
  onLoad?: () => void;
@@ -8926,7 +8966,7 @@ export declare interface WordTableBlockProps {
8926
8966
  editingName?: string;
8927
8967
  /** 지정하면 인쇄하기 왼쪽에 '취소' 버튼을 표시(PagePopup 닫기 등). */
8928
8968
  onCancel?: () => void;
8929
- /** 전달하면 카드 드래그로 순서 변경 가능 */
8969
+ /** 전달하면 카드 드래그로 순서 변경 가능. 쪽이 나뉘면 같은 쪽 안에서만 옮기며, 자리는 전체 목록 기준이다. */
8930
8970
  onReorder?: (fromIndex: number, toIndex: number) => void;
8931
8971
  /** display 모드에서도 칸 수 조절 허용 */
8932
8972
  showCellCount?: boolean;
@@ -8960,19 +9000,19 @@ export declare interface WordTableBlockProps {
8960
9000
 
8961
9001
  /**
8962
9002
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
8963
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
9003
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
8964
9004
  */
8965
9005
  export declare function WordTableButton({ items, name, description, createdAt, published, publishedLabel, shared, sharedLabel, isNew, publisherName, publishedAt, likeCount, downloadCount, onPublisherClick, selected, onClick, onMore, titleNode, className, }: WordTableButtonProps): JSX.Element;
8966
9006
 
8967
9007
  /** @public */
8968
9008
  export declare interface WordTableButtonProps {
8969
- /** 단어표 내용(썸네일 미리보기 + 개수) */
9009
+ /** 단어표 내용(썸네일 미리보기) */
8970
9010
  items: WordSearchResultItem[];
8971
9011
  /** 단어표 이름 */
8972
9012
  name: string;
8973
9013
  /** 설명 — 제목 아래 body-3로 표시(있을 때만) */
8974
9014
  description?: string;
8975
- /** 생성일(ISO) — 메타(날짜)에 표시. 없으면 개수만. */
9015
+ /** 생성일(ISO) — 메타(날짜)에 표시. */
8976
9016
  createdAt?: string;
8977
9017
  /** 스토어 발행 배지 표시 */
8978
9018
  published?: boolean;
@@ -10,7 +10,7 @@ import type { BarChartProps } from './barChartTypes';
10
10
  /**
11
11
  * 막대차트 섹션 — 제목 + 구간 네비게이션(이전/다음 + 드롭다운) + 월/주 토글 + 2계열 막대차트.
12
12
  * - 월(month): 월 드롭다운 + 주별 막대(1주~5주)
13
- * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(일~토) 막대
13
+ * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
14
14
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
15
15
  */
16
16
  export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, inGrid, }: BarChartProps): import("react").JSX.Element;
@@ -7,6 +7,7 @@ import Dropdown from './Dropdown';
7
7
  import SegmentedControl from './SegmentedControl';
8
8
  import Button from './Button';
9
9
  import Loader from './Loader';
10
+ import { getTodayKstDate } from '../utils/date';
10
11
  import DescriptionTooltip from './Tooltip/DescriptionTooltip';
11
12
  import ChartSection, { ChartTotal, ChartLegend } from './ChartSection';
12
13
  import { firstMondayOfMonth, KO_ORD } from './barChartData';
@@ -142,7 +143,7 @@ function 주의날들(data, monday, now) {
142
143
  function barsFromControl(data, control, t, lang) {
143
144
  const { period, monthRange, weekRange, weekRanges, DOW } = control;
144
145
  const isKo = lang?.startsWith('ko');
145
- const now = new Date();
146
+ const now = getTodayKstDate(); // 칸은 KST 달력 — 오늘·이번 주 표시도 KST 로
146
147
  const nowY = now.getFullYear(), nowM = now.getMonth() + 1, nowD = now.getDate();
147
148
  if (period === 'month') {
148
149
  const md = data[monthRange] ?? { weekly: [], daily: [] };
@@ -197,7 +198,7 @@ const BAR_SIZE_PRESET = {
197
198
  /**
198
199
  * 막대차트 섹션 — 제목 + 구간 네비게이션(이전/다음 + 드롭다운) + 월/주 토글 + 2계열 막대차트.
199
200
  * - 월(month): 월 드롭다운 + 주별 막대(1주~5주)
200
- * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(일~토) 막대
201
+ * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
201
202
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
202
203
  */
203
204
  export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, tightMax = false, avgMode = false, totalFormat, series, loading = false, onDetail, inGrid = false, }) {
@@ -216,7 +217,7 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
216
217
  if (!rate)
217
218
  return rawBars;
218
219
  const 월 = control.period === 'month' ? control.monthRange.match(/^(\d+)년 (\d+)월$/) : null;
219
- const now = new Date();
220
+ const now = getTodayKstDate();
220
221
  return rawBars.map((d, i) => {
221
222
  let a = d.a === -1 ? -1 : (d.b > 0 ? Math.round((d.a / d.b) * 100) : 0);
222
223
  if (월 && d.a !== -1) {
@@ -76,7 +76,11 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
76
76
  badgeCount: it.badgeCount,
77
77
  selected: it.key === activeKey,
78
78
  closeOnSelect: true,
79
+ // "…" 로 넘어간 꺼진 항목도 꺼진 채로 — 줄에서는 막히는데 메뉴에서는 골라졌다
80
+ disabled: it.state === 'disabled',
79
81
  onClick: () => {
82
+ if (it.state === 'disabled')
83
+ return;
80
84
  if (it.key && onChange)
81
85
  onChange(it.key);
82
86
  handleCloseMore();
@@ -107,14 +107,19 @@ export default function Button(props) {
107
107
  const hasSub = subLabel !== undefined;
108
108
  return (_jsxs("span", { className: `button__label-text${hasSub ? ' button__label-text--has-sub' : ''}`, children: [displayedLabel, count != null && _jsx("span", { className: "button__count", children: count }), hasSub && _jsx("span", { className: "button__sub-label-text caption-1", children: subLabel })] }));
109
109
  };
110
+ /* 떠날 때 비제어 선택 표시를 푼다 — **toggleable 은 제외**(눌러 켠 것이 포인터가 떠나면 꺼졌다).
111
+ blur 도 이 정리를 쓰지만 소비자의 onMouseLeave 는 부르지 않는다(포커스를 잃은 것은 마우스가 떠난 것이 아니다). */
112
+ const 떠남정리 = (e) => {
113
+ if (typeof selected !== 'boolean' && !toggleable)
114
+ setInternalSelected(false);
115
+ e.currentTarget.blur();
116
+ };
110
117
  const handleMouseLeave = (e) => {
111
118
  if (disabledProp)
112
119
  return;
113
120
  if (typeof rest.onMouseLeave === 'function')
114
121
  rest.onMouseLeave(e);
115
- if (typeof selected !== 'boolean')
116
- setInternalSelected(false);
117
- e.currentTarget.blur();
122
+ 떠남정리(e);
118
123
  };
119
124
  const handleClickInternal = (e) => {
120
125
  if (disabledProp) {
@@ -202,7 +207,8 @@ export default function Button(props) {
202
207
  if (!disabledProp && typeof rest.onFocus === 'function')
203
208
  rest.onFocus(e);
204
209
  }, onBlur: (e) => {
205
- handleMouseLeave(e);
210
+ if (!disabledProp)
211
+ 떠남정리(e);
206
212
  hideTooltipFn();
207
213
  if (!disabledProp && typeof rest.onBlur === 'function')
208
214
  rest.onBlur(e);
@@ -195,9 +195,13 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
195
195
  e.preventDefault();
196
196
  }, [viewY, viewM, 활성일, 옮기기]);
197
197
  const selectDay = useCallback((d) => {
198
+ if (disabled)
199
+ return; // 열린 채 disabled 가 되면 남은 달력에서 고른 날이 그대로 나갔다
198
200
  onChange(toISO(viewY, viewM, d));
199
201
  setOpen(false);
200
- }, [viewY, viewM, onChange, setOpen]);
202
+ }, [viewY, viewM, onChange, setOpen, disabled]);
203
+ useEffect(() => { if (disabled)
204
+ setOpen(false); }, [disabled, setOpen]);
201
205
  const isDisabledDay = (d) => {
202
206
  const iso = toISO(viewY, viewM, d);
203
207
  if (min && iso < min)
@@ -7,6 +7,7 @@ import { cx } from '../utils/cx';
7
7
  import { lockBodyScroll } from '../hooks/bodyScrollLock';
8
8
  import { pushEsc } from '../hooks/escStack';
9
9
  import { useFocusTrap } from '../hooks/useFocusTrap';
10
+ import { rememberFocusReturn, resolveFocusReturn } from '../hooks/focusReturn';
10
11
  import './Dialog.css';
11
12
  function CloseIcon() {
12
13
  return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true, children: _jsx("path", { d: "M6 6l12 12M18 6 6 18" }) }));
@@ -45,13 +46,14 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
45
46
  닫으면 overflow:hidden 으로 영영 굳었다. */
46
47
  const 풀기 = lockBodyScroll();
47
48
  const 판 = surfaceRef.current;
49
+ rememberFocusReturn(판, 돌아갈곳);
48
50
  판?.focus();
49
51
  return () => {
50
52
  풀기();
51
53
  // 포커스가 이 창 안이거나 갈 곳을 잃었을 때만 되돌린다 — 이미 다른 창으로 옮겨 갔으면 빼앗지 않는다(Popup 과 같다)
52
54
  const 지금 = document.activeElement;
53
55
  if (!지금 || 지금 === document.body || (판 && 판.contains(지금)))
54
- 돌아갈곳?.focus?.();
56
+ resolveFocusReturn(돌아갈곳)?.focus?.();
55
57
  };
56
58
  }, [open]);
57
59
  /* ESC 는 **공유 스택**에 맡긴다 — 예전에는 window 에서 직접 들었다.
@@ -62,11 +62,15 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
62
62
  const toggle = useCallback(() => { if (!disabled)
63
63
  rawToggle(); }, [disabled, rawToggle]);
64
64
  const select = useCallback((opt) => {
65
- if (opt.disabled)
65
+ // 열린 채 disabled 가 되면(저장 중 잠금) 남은 메뉴에서 고른 것이 그대로 onChange 로 나갔다
66
+ if (opt.disabled || disabled)
66
67
  return;
67
68
  onChange?.(opt.value);
68
69
  setOpen(false);
69
- }, [onChange, setOpen]);
70
+ }, [onChange, setOpen, disabled]);
71
+ // 막히면 열린 메뉴도 닫는다
72
+ useEffect(() => { if (disabled)
73
+ setOpen(false); }, [disabled, setOpen]);
70
74
  const handleKeyDown = useCallback((e) => {
71
75
  if (disabled)
72
76
  return;
@@ -92,6 +92,8 @@ export default function FileUpload({ onFilesSelected, accept, multiple = false,
92
92
  inputRef.current?.click();
93
93
  };
94
94
  const handleRemove = (index) => {
95
+ if (disabled)
96
+ return; // 꺼진 칸의 목록 제거 단추가 눌렸다
95
97
  /* onRemove 를 주면 알림은 그쪽 한 번으로 끝낸다(onFilesSelected 로 이중으로 알리지 않는다). 다만 비제어 모드에서는
96
98
  **보이는 목록도 줄인다** — 전에는 줄이기 전에 돌아가 버려 뺀 파일이 화면에 남았다. 제어 모드(files 를 부모가
97
99
  쥠)는 부모가 onRemove 에서 줄인다. */
@@ -108,5 +110,5 @@ export default function FileUpload({ onFilesSelected, accept, multiple = false,
108
110
  return (_jsxs("div", { className: `file-upload ${className}`.trim(), children: [_jsxs("div", { className: cx('file-upload__dropzone', isDragging && 'file-upload__dropzone--dragging', disabled && 'file-upload__dropzone--disabled'), role: "button", tabIndex: disabled ? -1 : 0, "aria-disabled": disabled, onClick: handleBrowseClick, onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') {
109
111
  e.preventDefault();
110
112
  handleBrowseClick();
111
- } }, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, children: [_jsx(Icon, { icon: "upload", size: 24, className: "file-upload__icon" }), _jsx("span", { className: "file-upload__prompt body-2", children: t('fileUpload.dragPrompt', '파일을 이곳에 드래그하여 업로드') }), _jsx("button", { type: "button", className: "file-upload__browse-button body-3", disabled: disabled, onClick: e => { e.stopPropagation(); handleBrowseClick(); }, children: t('fileUpload.browseButton', '파일 선택') }), _jsx("input", { ref: inputRef, type: "file", className: "file-upload__input", accept: accept, multiple: multiple, disabled: disabled, onChange: handleInputChange, tabIndex: -1 })] }), showFileList && files.length > 0 && (_jsx("ul", { className: "file-upload__list", children: files.map((file, index) => (_jsxs("li", { className: "file-upload__list-item body-3", children: [_jsx("span", { className: "file-upload__file-name body-3", children: file.name }), _jsx("button", { type: "button", className: "file-upload__remove-button", "aria-label": t('fileUpload.removeAriaLabel', { name: file.name }), onClick: () => handleRemove(index), children: _jsx(Icon, { icon: "x", size: 14 }) })] }, `${file.name}-${index}`))) }))] }));
113
+ } }, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, children: [_jsx(Icon, { icon: "upload", size: 24, className: "file-upload__icon" }), _jsx("span", { className: "file-upload__prompt body-2", children: t('fileUpload.dragPrompt', '파일을 이곳에 드래그하여 업로드') }), _jsx("button", { type: "button", className: "file-upload__browse-button body-3", disabled: disabled, onClick: e => { e.stopPropagation(); handleBrowseClick(); }, children: t('fileUpload.browseButton', '파일 선택') }), _jsx("input", { ref: inputRef, type: "file", className: "file-upload__input", accept: accept, multiple: multiple, disabled: disabled, onChange: handleInputChange, tabIndex: -1 })] }), showFileList && files.length > 0 && (_jsx("ul", { className: "file-upload__list", children: files.map((file, index) => (_jsxs("li", { className: "file-upload__list-item body-3", children: [_jsx("span", { className: "file-upload__file-name body-3", children: file.name }), _jsx("button", { type: "button", className: "file-upload__remove-button", "aria-label": t('fileUpload.removeAriaLabel', { name: file.name }), disabled: disabled, onClick: () => handleRemove(index), children: _jsx(Icon, { icon: "x", size: 14 }) })] }, `${file.name}-${index}`))) }))] }));
112
114
  }
@@ -66,7 +66,8 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
66
66
  기본값 '선택 탭'으로 읽혀, 필터가 여럿인 화면에서 어느 그룹인지
67
67
  구분이 안 된다(단어표 검색 설정은 세그먼트가 열 개 가까이 된다). */
68
68
  ariaLabel: section.displayLabel ?? section.label, options: [
69
- ...(section.allOption ? [{ key: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체') }] : []),
69
+ // allOptionDisabled 가 버튼 표시에서만 먹고 segmented·dropdown 의 '전체' 는 눌렸다
70
+ ...(section.allOption ? [{ key: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체'), disabled: section.allOptionDisabled }] : []),
70
71
  ...items.map(item => {
71
72
  const value = typeof item === 'string' ? item : item.value;
72
73
  const displayLabel = typeof item !== 'string' ? item.displayLabel : undefined;
@@ -77,7 +78,7 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
77
78
  /* 아무것도 안 골랐으면 빈 값 — undefined 를 넘기면 SegmentedControl 의 기본값(첫 보기)이 걸려, 상태는
78
79
  비었는데 첫 보기가 골라진 것처럼 보였다(필터는 걸리지 않았는데 걸린 듯). */
79
80
  value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label), size: 32 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
80
- ...(section.allOption ? [{ value: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체') }] : []),
81
+ ...(section.allOption ? [{ value: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체'), disabled: section.allOptionDisabled }] : []),
81
82
  ...items.map(item => {
82
83
  const value = typeof item === 'string' ? item : item.value;
83
84
  const displayLabel = typeof item !== 'string' ? item.displayLabel : undefined;
@@ -114,6 +114,11 @@ export function PanelRow(props) {
114
114
  const p = props;
115
115
  right = p.children;
116
116
  }
117
+ /* error 는 Base 에 있어 **모든 variant** 가 받는다 — 그런데 input(TextField)·dropdown 만 문구를 그리고 나머지는 받아
118
+ 버렸다(소리방향성 결과 저장 폴더: custom 행에 error 를 줬는데 빨간 상자만 뜨고 까닭이 안 보였다). dropdown 과 같은 모양으로 붙인다. */
119
+ if (error && variant !== 'input' && variant !== 'dropdown') {
120
+ right = (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [right, _jsx("span", { className: "caption-1 panel-row__error", role: "alert", children: error })] }));
121
+ }
117
122
  const isLabelFixed = variant === 'input' || variant === 'dropdown' || ((variant === 'text' || variant === 'custom') && useFullWidth);
118
123
  // 위 children 모드와 동일한 키보드 처리 — 이쪽 경로에만 빠져 있어서,
119
124
  // variant 모드에 onClick을 주면 조용히 마우스 전용이 된다(현재 사용처는 없지만 예방).
@@ -67,7 +67,9 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
67
67
  말하고, 바꾸려면 줄 오른쪽 끝의 다른 단추를 눌러야 했다. 보는 자리와
68
68
  바꾸는 자리가 갈려 있으면 무엇이 무엇을 바꾸는지 짚이지 않는다.
69
69
  체크가 눌림을 삼키게 해 줄이 함께 열리지 않도록 막는다. */
70
- content: (_jsx("span", { className: "dlp-worn-check", onClick: e => e.stopPropagation(), children: _jsx(Checkbox, { checked: row.worn, disabled: !onWear || !onRelease, ariaLabel: row.worn
70
+ content: (_jsx("span", { className: "dlp-worn-check", onClick: e => e.stopPropagation(), children: _jsx(Checkbox, { checked: row.worn,
71
+ // 켤 수 있는지는 그 줄이 할 일로 본다 — 둘 중 하나만 준 화면(말귀 고객 상세)에서 체크가 통째로 잠겼다
72
+ disabled: row.worn ? !onRelease : !onWear, ariaLabel: row.worn
71
73
  ? t('customers.device.unwear', '착용 해제')
72
74
  : t('customers.device.wear', '착용'), onChange: next => { if (next)
73
75
  onWear?.(row);
@@ -62,6 +62,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
62
62
  return copy.sort((a, b) => b.items.length - a.items.length);
63
63
  return copy;
64
64
  }, [tables, sortKey]);
65
+ /* 목록을 받아(tables) 보여 주는데 onDelete 가 없으면 지울 길이 없다 — 전에는 브라우저 저장소에서 같은 id 의 표를 지우고
66
+ 보이는 목록은 그대로였다(보이지 않는 표를 지움). 그때는 삭제 단추를 두지 않는다. */
67
+ const canDelete = !!onDeleteProp || tablesProp === undefined;
65
68
  const handleDelete = useCallback((id, e) => {
66
69
  e.stopPropagation();
67
70
  if (onDeleteProp) {
@@ -84,7 +87,7 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
84
87
  const headerActions = useMemo(() => tables.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Dropdown, { options: VIEW_OPTIONS, value: viewMode, onChange: handleSetViewMode }), _jsx(Dropdown, { options: SORT_OPTIONS, value: sortKey, onChange: handleSetSortKey })] })) : undefined, [tables.length, viewMode, sortKey, handleSetViewMode, handleSetSortKey, VIEW_OPTIONS, SORT_OPTIONS]);
85
88
  return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content",
86
89
  // 고른 표가 지금 목록에 있을 때만 — 부모 목록에서 빠진 표를 가리키면 눌러도 아무 일이 없었다
87
- disabled: !selected || !tables.some(t => t.id === selected), onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' })] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: (item.words ?? []).length >= 2
90
+ disabled: !selected || !tables.some(t => t.id === selected), onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [canDelete && _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' })] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: (item.words ?? []).length >= 2
88
91
  ? `${item.words[0].label} · ${item.words[1].label}`
89
- : item.words?.[0]?.label ?? item.id }, i))) })] }), _jsx("span", { className: "load-table-popup__row-count caption-1", children: t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' }) }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
92
+ : item.words?.[0]?.label ?? item.id }, i))) })] }), _jsx("span", { className: "load-table-popup__row-count caption-1", children: t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' }) }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), canDelete && _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
90
93
  }
@@ -16,7 +16,7 @@ export type PopupButtonConfig = {
16
16
  /** @public */
17
17
  export interface PopupProps {
18
18
  title?: ReactNode;
19
- /** 제목 아래 보조 설명(부제). title이 있을 때만 렌더된다. */
19
+ /** 제목 아래 보조 설명(부제). 제목 줄이 그려질 때(useTitle 이고 title·headerActions·onClose 중 하나가 있을 때) 보인다. */
20
20
  subtitle?: ReactNode;
21
21
  headerActions?: ReactNode;
22
22
  children?: ReactNode | ((opts: PopupOptions) => ReactNode);
@@ -8,6 +8,7 @@ import { pushEsc } from "../../hooks/escStack";
8
8
  import { lockBodyScroll } from "../../hooks/bodyScrollLock";
9
9
  import "./Popup.css";
10
10
  import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
11
+ import { rememberFocusReturn, resolveFocusReturn } from '../../hooks/focusReturn';
11
12
  const ANIM_MS = 260;
12
13
  // 패널 안에서 Tab 순환 대상으로 삼을 요소들 — disabled/tabindex="-1"은 제외.
13
14
  /** @public */
@@ -90,6 +91,7 @@ export default function Popup(props) {
90
91
  return;
91
92
  const previouslyFocused = document.activeElement;
92
93
  const panel = panelRef.current;
94
+ rememberFocusReturn(panel, previouslyFocused);
93
95
  if (panel && !panel.contains(document.activeElement)) {
94
96
  const first = panel.querySelector(FOCUSABLE_SELECTOR);
95
97
  (first ?? panel).focus();
@@ -99,8 +101,9 @@ export default function Popup(props) {
99
101
  (미리보기 → 삭제 확인)에서는 닫힘 애니메이션(260ms) 뒤에 여기가 돌 때 포커스가 이미 새 창에 있는데,
100
102
  전에는 그것을 뒤 화면의 여는 단추로 빼앗아 Tab 가둠이 풀리고 Enter 가 뒤 화면을 눌렀다. */
101
103
  const active = document.activeElement;
104
+ // 기억한 곳이 앞 창과 함께 사라졌으면(미리보기 → 삭제 확인) 그 창이 돌려줄 곳으로
102
105
  if (!active || active === document.body || (panel && panel.contains(active)))
103
- previouslyFocused?.focus?.();
106
+ resolveFocusReturn(previouslyFocused)?.focus?.();
104
107
  };
105
108
  }, [inline]);
106
109
  /* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
@@ -184,7 +187,7 @@ export default function Popup(props) {
184
187
  e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}${height != null ? ' popup-panel--has-height' : ''}${fillBody ? ' popup-panel--fill-body' : ''}`,
185
188
  /* 높이는 **CSS 변수로** 넘긴다 — 인라인 height 로 박으면 앱의 미디어 규칙(좁은 화면만 다른 높이)이 못 이겼다.
186
189
  max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
187
- style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
190
+ style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && useTitle && title ? titleId : undefined, "aria-label": !inline && !(useTitle && title) ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
188
191
  if (inline || typeof document === 'undefined')
189
192
  return el;
190
193
  return createPortal(el, document.body);
@@ -12,7 +12,9 @@ import './TextField.css';
12
12
  const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
13
13
  /** @public */
14
14
  export default function TextField(props) {
15
- const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, 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, openOnFocus = false, } = props;
15
+ const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, 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
+ // 읽기 전용·꺼진 칸은 후보로 값을 바꾸지 못한다 — 전에는 ↓·Enter·후보 누름으로 onChange 가 나갔다
17
+ const suggestions = readOnly || disabled ? undefined : suggestionsProp;
16
18
  const { t } = useTranslation();
17
19
  const uid = useId();
18
20
  /* **이름 없는 입력칸은 화면을 못 보는 사람에게 빈칸이다.** placeholder 는 이름이
@@ -82,7 +82,7 @@ export default function Tooltip(props) {
82
82
  top = rect.top + rect.height / 2 - h / 2;
83
83
  }
84
84
  }
85
- const m = Number(edgeMargin) || 8;
85
+ const m = Number.isFinite(Number(edgeMargin)) ? Number(edgeMargin) : 8; // 0 도 값이다(`|| 8` 이 0 을 8 로 바꿨다)
86
86
  left = Math.min(Math.max(left, m), vw - w - m);
87
87
  top = Math.min(Math.max(top, m), vh - h - m);
88
88
  const leftRounded = Math.round(left);
@@ -78,7 +78,7 @@ export type TrainingPagePopupProps = {
78
78
  * 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
79
79
  */
80
80
  noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
81
- /** 훈련 결과 팝업 렌더러 — 없으면 결과 팝업을 표시하지 않음 */
81
+ /** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
82
82
  renderResultPopup?: (props: {
83
83
  score?: number | null;
84
84
  results?: unknown[];
@@ -5,6 +5,7 @@ import { PAGE_POPUP_CLOSE_MS } from './pagePopupTiming';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { createPortal } from "react-dom";
7
7
  import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
8
+ import { rememberFocusReturn, resolveFocusReturn } from '../../hooks/focusReturn';
8
9
  import { pushEsc } from '../../hooks/escStack';
9
10
  import Toast from '../Toast';
10
11
  import Button from '../Button';
@@ -240,6 +241,7 @@ function TrainingPagePopupInner(props) {
240
241
  useEffect(() => {
241
242
  const panel = panelRef.current;
242
243
  const previouslyFocused = document.activeElement;
244
+ rememberFocusReturn(panel, previouslyFocused);
243
245
  if (panel) {
244
246
  const firstEl = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'))[0];
245
247
  if (firstEl) {
@@ -254,7 +256,7 @@ function TrainingPagePopupInner(props) {
254
256
  const active = document.activeElement;
255
257
  if (!active || active === document.body || (panel && panel.contains(active))) {
256
258
  try {
257
- previouslyFocused?.focus?.();
259
+ resolveFocusReturn(previouslyFocused)?.focus?.();
258
260
  }
259
261
  catch (e) { }
260
262
  }