@ihabdevteam/core 0.141.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 (76) hide show
  1. package/CHANGELOG.md +73 -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 +81 -29
  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/BulkBar.js +15 -4
  11. package/dist/packages/core/src/components/Button.js +10 -4
  12. package/dist/packages/core/src/components/DatePicker.js +5 -1
  13. package/dist/packages/core/src/components/Dialog.js +3 -1
  14. package/dist/packages/core/src/components/Dropdown.js +6 -2
  15. package/dist/packages/core/src/components/FileUpload.js +3 -1
  16. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  17. package/dist/packages/core/src/components/PanelRow.js +5 -0
  18. package/dist/packages/core/src/components/PaymentPlan.d.ts +2 -2
  19. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  20. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  21. package/dist/packages/core/src/components/Popup/Popup.d.ts +6 -6
  22. package/dist/packages/core/src/components/Popup/Popup.js +11 -9
  23. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +1 -1
  24. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +1 -1
  25. package/dist/packages/core/src/components/TextField.js +3 -1
  26. package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -1
  27. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -1
  28. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  29. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +7 -1
  30. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +52 -2
  31. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +3 -1
  32. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  33. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  34. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  35. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  36. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  37. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  38. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  39. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  40. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  41. package/dist/packages/core/src/components/barChartData.js +7 -3
  42. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  43. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  44. package/dist/packages/core/src/components.d.ts +1 -0
  45. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  46. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  47. package/dist/packages/core/src/hooks/inputModality.d.ts +2 -0
  48. package/dist/packages/core/src/hooks/inputModality.js +31 -0
  49. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +12 -0
  50. package/dist/packages/core/src/hooks/useDocumentTitle.js +7 -6
  51. package/dist/packages/core/src/hooks/useEditableList.js +3 -3
  52. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  53. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  54. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
  55. package/dist/packages/core/src/utils/date.d.ts +18 -0
  56. package/dist/packages/core/src/utils/date.js +23 -0
  57. package/dist/packages/core/src/utils/format.js +2 -1
  58. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  59. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  60. package/dist/packages/core/src/utils/josa.js +2 -0
  61. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  62. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  63. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  64. package/dist/packages/core/src/utils/textSort.d.ts +10 -0
  65. package/dist/packages/core/src/utils/textSort.js +16 -11
  66. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  67. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  68. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  69. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  70. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  71. package/dist/packages/core/src/utils.d.ts +1 -1
  72. package/dist/packages/core/src/utils.js +1 -1
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +2 -2
  76. 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.141.0";
1389
+ export declare const CORE_VERSION = "0.143.0";
1380
1390
 
1381
1391
  /** @public */
1382
1392
  declare const coreDefault: Record<string, unknown>;
@@ -2206,7 +2216,7 @@ export declare function formatDateLongLocale(iso: string, lang: string): string;
2206
2216
  export declare function formatDatetimeKo(iso: string): string;
2207
2217
 
2208
2218
  /**
2209
- * 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
2219
+ * 초 → "M분 S초"(또는 "M분"·"S초"). 초 단위로 반올림한 뒤 나눈다 — 없거나(숫자가 아님) 반올림해 0 이하(0.5초 미만 포함)면 '-'.
2210
2220
  *
2211
2221
  * 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
2212
2222
  * 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
@@ -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
  *
@@ -3807,13 +3847,13 @@ export declare interface PaymentPlanTier {
3807
3847
  highlight?: boolean;
3808
3848
  /**
3809
3849
  * 할인 배지에 '오픈 특가'를 함께 얹는다 — **표시 전용**.
3810
- * 계정당 1회 제한은 라벨의 '오픈 특가' 문자열로 판정하므로, 이 플래그만으로는
3850
+ * 계정당 1회 제한은 `onceOnly`(없으면 라벨의 '오픈 특가' 문자열)로 판정하므로, 이 플래그만으로는
3811
3851
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
3812
3852
  */
3813
3853
  openPromo?: boolean;
3814
3854
  /**
3815
3855
  * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
3816
- * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
3856
+ * 안 주면 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
3817
3857
  * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
3818
3858
  */
3819
3859
  onceOnly?: boolean;
@@ -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);
@@ -4135,12 +4175,12 @@ export declare interface PopupProps {
4135
4175
  /**
4136
4176
  * 닫기 직전에 불린다. false를 반환하면 닫기를 중단한다(닫힘 애니메이션도 시작하지 않음).
4137
4177
  *
4138
- * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막으면 안 되는 이유 —
4139
- * onClose는 이미 애니메이션이 시작된 뒤(animateThen의 setTimeout 안)에 불리므로,
4140
- * 거기서 닫기를 취소해도 팝업은 closingRef=true인 채로 남아 이후 ESC·X가 전부
4141
- * 무시된다(영구히 못 닫는 상태). 그래서 판단은 반드시 이 시점에 해야 한다.
4178
+ * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
4179
+ * onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
4180
+ * 거기서 닫기를 취소하면 창이 한 번 사라졌다가 되살아난다(animateThen 은 콜백 뒤에도 창이
4181
+ * 남아 있으면 되살린다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
4142
4182
  *
4143
- * 미지정이면 기존 동작과 완전히 동일하다.
4183
+ * 미지정이면 묻지 않고 닫는다.
4144
4184
  */
4145
4185
  beforeClose?: () => boolean;
4146
4186
  /**
@@ -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[];
@@ -7920,6 +7960,18 @@ export declare function useDeviceManagement(): {
7920
7960
  handleSelectFromList: (form: DeviceForm) => void;
7921
7961
  };
7922
7962
 
7963
+ /**
7964
+ * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
7965
+ *
7966
+ * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
7967
+ * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
7968
+ * 여러 화면이 함께 걸면 제목이 쌓여 가장 나중에 건 것이 보이고, 언마운트하면
7969
+ * 남은 것 중 가장 나중에 건 제목으로(남은 것이 없으면 처음 제목으로) 되돌린다.
7970
+ *
7971
+ * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
7972
+ *
7973
+ * @public
7974
+ */
7923
7975
  export declare function useDocumentTitle(title?: string | null): void;
7924
7976
 
7925
7977
  /**
@@ -8703,9 +8755,9 @@ export declare interface WordFilterSnapshot {
8703
8755
  sameStructureOnly: boolean;
8704
8756
  phonemeView: 'char' | 'pron' | 'direct';
8705
8757
  gradeFilter: string[];
8706
- /** 품사 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
8758
+ /** 품사 필터 — 고정 코드(noun·verb·modifier). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
8707
8759
  posFilter?: string[];
8708
- /** 어종 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
8760
+ /** 어종 필터 — 고정 코드(native·sino·foreign·mixed). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
8709
8761
  wordTypeFilter?: string[];
8710
8762
  }
8711
8763
 
@@ -8888,7 +8940,7 @@ export declare interface WordTableBlockProps {
8888
8940
  tableName?: string;
8889
8941
  /** 전달하면 외부 데이터 표시 모드, 없으면 편집 모드 */
8890
8942
  items?: WordSearchResultItem[];
8891
- /** 전달하면 저장 팝업을 내부에서 열고 확인 후 name·items·description을 전달.
8943
+ /** 전달하면 저장하기가 저장 팝업(`renderSavePopup` 이 그림 — 없으면 아무것도 뜨지 않는다)을 열고 확인 후 name·items·description을 전달.
8892
8944
  * saveAsNew=true면 '다른 이름으로 저장'(수정 대신 새 표 생성)을 뜻한다. */
8893
8945
  onSaveComplete?: (name: string, items: WordSearchResultItem[], description?: string, saveAsNew?: boolean) => void;
8894
8946
  onLoad?: () => void;
@@ -8914,7 +8966,7 @@ export declare interface WordTableBlockProps {
8914
8966
  editingName?: string;
8915
8967
  /** 지정하면 인쇄하기 왼쪽에 '취소' 버튼을 표시(PagePopup 닫기 등). */
8916
8968
  onCancel?: () => void;
8917
- /** 전달하면 카드 드래그로 순서 변경 가능 */
8969
+ /** 전달하면 카드 드래그로 순서 변경 가능. 쪽이 나뉘면 같은 쪽 안에서만 옮기며, 자리는 전체 목록 기준이다. */
8918
8970
  onReorder?: (fromIndex: number, toIndex: number) => void;
8919
8971
  /** display 모드에서도 칸 수 조절 허용 */
8920
8972
  showCellCount?: boolean;
@@ -8948,19 +9000,19 @@ export declare interface WordTableBlockProps {
8948
9000
 
8949
9001
  /**
8950
9002
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
8951
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
9003
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
8952
9004
  */
8953
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;
8954
9006
 
8955
9007
  /** @public */
8956
9008
  export declare interface WordTableButtonProps {
8957
- /** 단어표 내용(썸네일 미리보기 + 개수) */
9009
+ /** 단어표 내용(썸네일 미리보기) */
8958
9010
  items: WordSearchResultItem[];
8959
9011
  /** 단어표 이름 */
8960
9012
  name: string;
8961
9013
  /** 설명 — 제목 아래 body-3로 표시(있을 때만) */
8962
9014
  description?: string;
8963
- /** 생성일(ISO) — 메타(날짜)에 표시. 없으면 개수만. */
9015
+ /** 생성일(ISO) — 메타(날짜)에 표시. */
8964
9016
  createdAt?: string;
8965
9017
  /** 스토어 발행 배지 표시 */
8966
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();
@@ -56,10 +56,21 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
56
56
  return;
57
57
  const obs = new ResizeObserver(check);
58
58
  obs.observe(el);
59
- // 창 폭이 바뀌면 기억한 가로 칸 폭은 옛 것이다 — 버리고 다시 잰다
60
- const 창바뀜 = () => { 가로칸폭Ref.current = null; check(); };
61
- window.addEventListener('resize', 창바뀜);
62
- return () => { obs.disconnect(); window.removeEventListener('resize', 창바뀜); };
59
+ /* 막대 자체의 폭이 바뀌면(창 크기뿐 아니라 옆 메뉴 접기로 본문 열이 넓어질 때도) 기억한 가로 칸 폭은 옛 것이다 — 버리고 다시
60
+ 잰다. 안 그러면 넘친 순간의 좁은 폭을 들고 있어 넓어져도 쌓임이 영영 안 풀렸다. 단추 배치로는 막대 폭이 안 바뀐다. */
61
+ const 막대 = el.parentElement;
62
+ let 막대폭 = 막대?.clientWidth ?? 0;
63
+ const 막대obs = new ResizeObserver(() => {
64
+ const w = 막대?.clientWidth ?? 0;
65
+ if (w === 막대폭)
66
+ return;
67
+ 막대폭 = w;
68
+ 가로칸폭Ref.current = null;
69
+ check();
70
+ });
71
+ if (막대)
72
+ 막대obs.observe(막대);
73
+ return () => { obs.disconnect(); 막대obs.disconnect(); };
63
74
  }, [content, count]);
64
75
  return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
65
76
  /* 숨은 막대는 CSS 로 투명(opacity 0)할 뿐 DOM 에 그대로 있다 — 예전에는 그 안의
@@ -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을 주면 조용히 마우스 전용이 된다(현재 사용처는 없지만 예방).
@@ -16,13 +16,13 @@ export interface PaymentPlanTier {
16
16
  highlight?: boolean;
17
17
  /**
18
18
  * 할인 배지에 '오픈 특가'를 함께 얹는다 — **표시 전용**.
19
- * 계정당 1회 제한은 라벨의 '오픈 특가' 문자열로 판정하므로, 이 플래그만으로는
19
+ * 계정당 1회 제한은 `onceOnly`(없으면 라벨의 '오픈 특가' 문자열)로 판정하므로, 이 플래그만으로는
20
20
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
21
21
  */
22
22
  openPromo?: boolean;
23
23
  /**
24
24
  * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
25
- * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
25
+ * 안 주면 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
26
26
  * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
27
27
  */
28
28
  onceOnly?: boolean;
@@ -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);
@@ -38,12 +38,12 @@ export interface PopupProps {
38
38
  /**
39
39
  * 닫기 직전에 불린다. false를 반환하면 닫기를 중단한다(닫힘 애니메이션도 시작하지 않음).
40
40
  *
41
- * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막으면 안 되는 이유 —
42
- * onClose는 이미 애니메이션이 시작된 뒤(animateThen의 setTimeout 안)에 불리므로,
43
- * 거기서 닫기를 취소해도 팝업은 closingRef=true인 채로 남아 이후 ESC·X가 전부
44
- * 무시된다(영구히 못 닫는 상태). 그래서 판단은 반드시 이 시점에 해야 한다.
41
+ * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
42
+ * onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
43
+ * 거기서 닫기를 취소하면 창이 한 번 사라졌다가 되살아난다(animateThen 은 콜백 뒤에도 창이
44
+ * 남아 있으면 되살린다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
45
45
  *
46
- * 미지정이면 기존 동작과 완전히 동일하다.
46
+ * 미지정이면 묻지 않고 닫는다.
47
47
  */
48
48
  beforeClose?: () => boolean;
49
49
  /**