@ihabdevteam/core 0.142.0 → 0.144.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 (79) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/components.cjs.js +185 -180
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +191 -186
  6. package/dist/ihabdevteam-core.d.ts +66 -22
  7. package/dist/packages/core/src/components/Badge.js +3 -1
  8. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  9. package/dist/packages/core/src/components/BarChart.js +4 -3
  10. package/dist/packages/core/src/components/BottomNav.js +4 -0
  11. package/dist/packages/core/src/components/BulkBar.js +1 -1
  12. package/dist/packages/core/src/components/Button.js +12 -5
  13. package/dist/packages/core/src/components/Calendar.js +9 -6
  14. package/dist/packages/core/src/components/DatePicker.js +9 -4
  15. package/dist/packages/core/src/components/Dialog.js +3 -1
  16. package/dist/packages/core/src/components/Dropdown.d.ts +3 -1
  17. package/dist/packages/core/src/components/Dropdown.js +10 -5
  18. package/dist/packages/core/src/components/FileUpload.js +3 -1
  19. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  20. package/dist/packages/core/src/components/Loader.js +6 -1
  21. package/dist/packages/core/src/components/Pagination.js +1 -1
  22. package/dist/packages/core/src/components/PanelRow.js +13 -4
  23. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  24. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  25. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  26. package/dist/packages/core/src/components/Popup/Popup.js +26 -6
  27. package/dist/packages/core/src/components/ProgressBar.js +1 -1
  28. package/dist/packages/core/src/components/SideNav.js +1 -1
  29. package/dist/packages/core/src/components/Slider.js +10 -1
  30. package/dist/packages/core/src/components/Table.js +2 -0
  31. package/dist/packages/core/src/components/TextField.js +3 -1
  32. package/dist/packages/core/src/components/Toolbar.js +3 -1
  33. package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -2
  34. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  35. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
  36. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +49 -2
  37. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  38. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  39. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  40. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  41. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  42. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  43. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  44. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  45. package/dist/packages/core/src/components/Word/wordTablePrint.js +27 -8
  46. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  47. package/dist/packages/core/src/components/barChartData.js +7 -3
  48. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  49. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  50. package/dist/packages/core/src/components/useCalendarMonth.js +3 -2
  51. package/dist/packages/core/src/components/useToastLifecycle.js +5 -0
  52. package/dist/packages/core/src/components.d.ts +1 -0
  53. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  54. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  55. package/dist/packages/core/src/hooks/useVisitHistory.d.ts +2 -0
  56. package/dist/packages/core/src/hooks/useVisitHistory.js +5 -6
  57. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  58. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  59. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +57 -17
  60. package/dist/packages/core/src/utils/date.d.ts +18 -0
  61. package/dist/packages/core/src/utils/date.js +23 -0
  62. package/dist/packages/core/src/utils/format.js +2 -1
  63. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  64. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  65. package/dist/packages/core/src/utils/josa.js +2 -0
  66. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  67. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  68. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  69. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  70. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  71. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  72. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  73. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  74. package/dist/packages/core/src/utils.d.ts +1 -1
  75. package/dist/packages/core/src/utils.js +1 -1
  76. package/dist/packages/core/src/version.d.ts +1 -1
  77. package/dist/packages/core/src/version.js +1 -1
  78. package/dist/utils.cjs.js +2 -2
  79. 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.144.0";
1380
1390
 
1381
1391
  /** @public */
1382
1392
  declare const coreDefault: Record<string, unknown>;
@@ -1820,7 +1830,7 @@ export declare interface DraftItem<T> {
1820
1830
  export declare function draftValues<T>(draft: readonly DraftItem<T>[]): T[];
1821
1831
 
1822
1832
  /** @public */
1823
- export declare function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel }: DropdownProps): JSX.Element;
1833
+ export declare function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel, ariaDescribedBy }: DropdownProps): JSX.Element;
1824
1834
 
1825
1835
  export declare type DropdownItem = {
1826
1836
  options: {
@@ -1857,6 +1867,8 @@ export declare interface DropdownProps {
1857
1867
  className?: string;
1858
1868
  /** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용 */
1859
1869
  ariaLabel?: string;
1870
+ /** 트리거를 설명할 요소 id(들) — 오류 문구 등. 고른 값은 이것과 함께 설명으로 따로 읽힌다. */
1871
+ ariaDescribedBy?: string;
1860
1872
  }
1861
1873
 
1862
1874
  /**
@@ -2267,11 +2279,13 @@ export declare interface FormatYmdOptions {
2267
2279
  /** @public */
2268
2280
  export declare function getAssetBaseUrl(): string;
2269
2281
 
2270
- /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
2271
- export declare function getControlDateRange(control: BarChartControl): {
2272
- from: Date;
2273
- to: Date;
2274
- };
2282
+ /**
2283
+ * 현재 컨트롤 선택(주/월)의 기간 — 상세보기(기간별 리스트)용. **KST 의 날로 자른다**(해외 기기도 같은 기간).
2284
+ *
2285
+ * @param offset 몇 기간 앞·뒤인가 — `-1` 이면 앞 달 한 달(달력 달 그대로, 30일 앞이 아니다) · 앞 주 한 주.
2286
+ * @public
2287
+ */
2288
+ export declare function getControlDateRange(control: BarChartControl, offset?: number): ControlDateRange;
2275
2289
 
2276
2290
  /** @public */
2277
2291
  export declare function getDailySelection(): {
@@ -2383,6 +2397,14 @@ export declare function getSteppedPayload(pageAnswers: unknown, itemIndex: numbe
2383
2397
  /** KST(Asia/Seoul) 기준 오늘 날짜를 'YYYY-MM-DD'로 반환. DB 날짜 비교 기준선으로 사용. */
2384
2398
  export declare function getTodayKst(): string;
2385
2399
 
2400
+ /**
2401
+ * KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
2402
+ * '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
2403
+ *
2404
+ * @public
2405
+ */
2406
+ export declare function getTodayKstDate(): Date;
2407
+
2386
2408
  /**
2387
2409
  * 등록된 주제 카테고리 목록을 반환합니다.
2388
2410
  * configureTrainingCategories() 미호출 시 기본값을 반환합니다.
@@ -2647,6 +2669,14 @@ export declare const JONGSEONG: readonly ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ",
2647
2669
 
2648
2670
  export declare const JUNGSEONG: readonly ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"];
2649
2671
 
2672
+ /**
2673
+ * KST 달력 날짜(연, 0부터 세는 월, 일)의 **00:00 KST 순간.** 일·월은 넘쳐도 된다(`Date.UTC` 처럼 — 32일은 다음 달 1일).
2674
+ * 서버에 기간을 물을 때(`toISOString()`) 기기 시간대가 아니라 KST 의 하루로 자르려고 쓴다.
2675
+ *
2676
+ * @public
2677
+ */
2678
+ export declare function kstStartOfDay(year: number, month0: number, day: number): Date;
2679
+
2650
2680
  /**
2651
2681
  * 칸 번호 → 그때 보인 낱말. 채점과 **함께 저장한다.**
2652
2682
  *
@@ -3543,6 +3573,18 @@ export declare type PanelRowToggle = Base & {
3543
3573
 
3544
3574
  declare type PanelSize = 'small' | 'medium' | 'large';
3545
3575
 
3576
+ /**
3577
+ * 'YYYY-MM-DD' 날짜 전용 문자열(예: 서버가 KST 기준으로 집계해 내려주는 log_date)을
3578
+ * 로컬 타임존 자정 Date로 파싱한다. new Date('YYYY-MM-DD')는 UTC 자정으로 해석되어,
3579
+ * UTC보다 서쪽 시간대(예: 미국)에서 getDate()/getMonth() 같은 로컬 getter로 읽으면
3580
+ * 하루 전으로 밀린다 — 차트를 날짜별로 버킷팅할 때 특히 문제가 된다.
3581
+ *
3582
+ * 시각(ISO)에서 KST 의 날을 달력 Date 로 얻으려면 `parseLocalYmd(toKstYmd(시각))`.
3583
+ *
3584
+ * @public
3585
+ */
3586
+ export declare function parseLocalYmd(ymd: string): Date;
3587
+
3546
3588
  /**
3547
3589
  * 드롭다운 값을 정렬 상태로 되돌린다.
3548
3590
  *
@@ -4113,7 +4155,7 @@ export declare type PopupOptions = {
4113
4155
  /** @public */
4114
4156
  export declare interface PopupProps {
4115
4157
  title?: ReactNode;
4116
- /** 제목 아래 보조 설명(부제). title이 있을 때만 렌더된다. */
4158
+ /** 제목 아래 보조 설명(부제). 제목 줄이 그려질 때(useTitle 이고 title·headerActions·onClose 중 하나가 있을 때) 보인다. */
4117
4159
  subtitle?: ReactNode;
4118
4160
  headerActions?: ReactNode;
4119
4161
  children?: ReactNode | ((opts: PopupOptions) => ReactNode);
@@ -6484,6 +6526,8 @@ export declare const toBase64UrlSafe: typeof toStorageName;
6484
6526
  export declare function toChoseong(text: string): string;
6485
6527
 
6486
6528
  /**
6529
+ * 오늘의 **KST** 연·월·일(두 자리 월·일). 방문 폼의 기본 날짜 — 소비자가 이 칸을 KST 벽시계로 읽어 저장하므로, 기기 달력이면
6530
+ * 해외 기기에서 손대지 않고 등록한 방문이 하루 전으로 저장됐다(해외 기기 공식 지원).
6487
6531
  * @public
6488
6532
  */
6489
6533
  export declare const today: () => {
@@ -7296,7 +7340,7 @@ export declare type TrainingPagePopupProps = {
7296
7340
  * 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
7297
7341
  */
7298
7342
  noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
7299
- /** 훈련 결과 팝업 렌더러 — 없으면 결과 팝업을 표시하지 않음 */
7343
+ /** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
7300
7344
  renderResultPopup?: (props: {
7301
7345
  score?: number | null;
7302
7346
  results?: unknown[];
@@ -8715,9 +8759,9 @@ export declare interface WordFilterSnapshot {
8715
8759
  sameStructureOnly: boolean;
8716
8760
  phonemeView: 'char' | 'pron' | 'direct';
8717
8761
  gradeFilter: string[];
8718
- /** 품사 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
8762
+ /** 품사 필터 — 고정 코드(noun·verb·modifier). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
8719
8763
  posFilter?: string[];
8720
- /** 어종 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
8764
+ /** 어종 필터 — 고정 코드(native·sino·foreign·mixed). 라벨(옛 스냅샷)도 받는다. 없으면 되돌릴 때 지금 값을 그대로 둔다. */
8721
8765
  wordTypeFilter?: string[];
8722
8766
  }
8723
8767
 
@@ -8900,7 +8944,7 @@ export declare interface WordTableBlockProps {
8900
8944
  tableName?: string;
8901
8945
  /** 전달하면 외부 데이터 표시 모드, 없으면 편집 모드 */
8902
8946
  items?: WordSearchResultItem[];
8903
- /** 전달하면 저장 팝업을 내부에서 열고 확인 후 name·items·description을 전달.
8947
+ /** 전달하면 저장하기가 저장 팝업(`renderSavePopup` 이 그림 — 없으면 아무것도 뜨지 않는다)을 열고 확인 후 name·items·description을 전달.
8904
8948
  * saveAsNew=true면 '다른 이름으로 저장'(수정 대신 새 표 생성)을 뜻한다. */
8905
8949
  onSaveComplete?: (name: string, items: WordSearchResultItem[], description?: string, saveAsNew?: boolean) => void;
8906
8950
  onLoad?: () => void;
@@ -8926,7 +8970,7 @@ export declare interface WordTableBlockProps {
8926
8970
  editingName?: string;
8927
8971
  /** 지정하면 인쇄하기 왼쪽에 '취소' 버튼을 표시(PagePopup 닫기 등). */
8928
8972
  onCancel?: () => void;
8929
- /** 전달하면 카드 드래그로 순서 변경 가능 */
8973
+ /** 전달하면 카드 드래그로 순서 변경 가능. 쪽이 나뉘면 같은 쪽 안에서만 옮기며, 자리는 전체 목록 기준이다. */
8930
8974
  onReorder?: (fromIndex: number, toIndex: number) => void;
8931
8975
  /** display 모드에서도 칸 수 조절 허용 */
8932
8976
  showCellCount?: boolean;
@@ -8960,19 +9004,19 @@ export declare interface WordTableBlockProps {
8960
9004
 
8961
9005
  /**
8962
9006
  * @public 단어표를 '문서형' 미리보기 카드로 표현하는 컴포넌트.
8963
- * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜·개수 + (발행 배지 / 더보기 / 스토어 푸터).
9007
+ * 내용 썸네일(WordTableThumbnail) + 이름 + 날짜 + (발행 배지 / 더보기 / 스토어 푸터).
8964
9008
  */
8965
9009
  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
9010
 
8967
9011
  /** @public */
8968
9012
  export declare interface WordTableButtonProps {
8969
- /** 단어표 내용(썸네일 미리보기 + 개수) */
9013
+ /** 단어표 내용(썸네일 미리보기) */
8970
9014
  items: WordSearchResultItem[];
8971
9015
  /** 단어표 이름 */
8972
9016
  name: string;
8973
9017
  /** 설명 — 제목 아래 body-3로 표시(있을 때만) */
8974
9018
  description?: string;
8975
- /** 생성일(ISO) — 메타(날짜)에 표시. 없으면 개수만. */
9019
+ /** 생성일(ISO) — 메타(날짜)에 표시. */
8976
9020
  createdAt?: string;
8977
9021
  /** 스토어 발행 배지 표시 */
8978
9022
  published?: boolean;
@@ -49,7 +49,9 @@ export default function Badge(props) {
49
49
  내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
50
50
  e.preventDefault();
51
51
  e.currentTarget.click();
52
- } : undefined, "aria-label": ariaHidden ? undefined : ariaLabel ?? defaultLabel, "aria-hidden": ariaHidden || undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined && dotIcon === 'check' && _jsx(CheckIcon, {}), number === undefined && dotIcon === 'remove' && _jsx(RemoveIcon, {}), number === undefined && dotIcon === 'minus' && _jsx(MinusIcon, {})] }));
52
+ } : undefined, "aria-label": ariaHidden ? undefined : ariaLabel ?? defaultLabel, "aria-hidden": ariaHidden || undefined,
53
+ // 누를 수 없는 점도 이름이 있으면 그림(img)으로 — 역할 없는 span 의 aria-label 은 낭독기가 버려 "선택됨" 이 안 들렸다(bar 와 같게)
54
+ role: onClick ? 'button' : (ariaHidden ? undefined : 'img'), tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined && dotIcon === 'check' && _jsx(CheckIcon, {}), number === undefined && dotIcon === 'remove' && _jsx(RemoveIcon, {}), number === undefined && dotIcon === 'minus' && _jsx(MinusIcon, {})] }));
53
55
  }
54
56
  // ── bar (텍스트) ────────────────────────────────────────────────────────
55
57
  const { label, children, variant = 'default', fill = 'filled', minWidth, iconLeft, iconRight, color } = props;
@@ -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();
@@ -77,5 +77,5 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
77
77
  단추가 Tab 순서와 낭독기에 남아, 보이지 않는 '선택 해제'·'삭제' 에 포커스가 갔다.
78
78
  inert 로 포커스·낭독기에서 함께 뺀다(보이는 모양은 CSS 그대로). aria-hidden 은
79
79
  inert 를 모르는 오래된 브라우저를 위한 덧댐이다. */
80
- inert: !show || undefined, "aria-hidden": show ? undefined : true, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}` })), _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)))] })] }));
80
+ 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 && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}` })), _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)))] })] }));
81
81
  }
@@ -91,9 +91,10 @@ export default function Button(props) {
91
91
  }, [disabledProp]);
92
92
  const isSelected = typeof selected === 'boolean' ? Boolean(selected) : internalSelected;
93
93
  // toggleable 또는 controlled selected가 있을 때만 aria-pressed 설정
94
+ // 그 밖에는 소비자가 넘긴 aria-pressed 를 그대로 둔다(아이콘이 바뀌는 재생/멈춤 단추 등) — 전에는 undefined 로 덮었다
94
95
  const ariaPressed = (toggleable || typeof selected === 'boolean')
95
96
  ? (isSelected ? 'true' : 'false')
96
- : undefined;
97
+ : rest['aria-pressed'];
97
98
  const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, tooltipOnly ? 'button--tooltip-only' : null, className);
98
99
  const iconClassName = `button__icon button__icon--${iconSize}`;
99
100
  const subLabels = Array.isArray(subLabel) ? subLabel : (subLabel !== undefined ? [subLabel] : []);
@@ -107,14 +108,19 @@ export default function Button(props) {
107
108
  const hasSub = subLabel !== undefined;
108
109
  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
110
  };
111
+ /* 떠날 때 비제어 선택 표시를 푼다 — **toggleable 은 제외**(눌러 켠 것이 포인터가 떠나면 꺼졌다).
112
+ blur 도 이 정리를 쓰지만 소비자의 onMouseLeave 는 부르지 않는다(포커스를 잃은 것은 마우스가 떠난 것이 아니다). */
113
+ const 떠남정리 = (e) => {
114
+ if (typeof selected !== 'boolean' && !toggleable)
115
+ setInternalSelected(false);
116
+ e.currentTarget.blur();
117
+ };
110
118
  const handleMouseLeave = (e) => {
111
119
  if (disabledProp)
112
120
  return;
113
121
  if (typeof rest.onMouseLeave === 'function')
114
122
  rest.onMouseLeave(e);
115
- if (typeof selected !== 'boolean')
116
- setInternalSelected(false);
117
- e.currentTarget.blur();
123
+ 떠남정리(e);
118
124
  };
119
125
  const handleClickInternal = (e) => {
120
126
  if (disabledProp) {
@@ -202,7 +208,8 @@ export default function Button(props) {
202
208
  if (!disabledProp && typeof rest.onFocus === 'function')
203
209
  rest.onFocus(e);
204
210
  }, onBlur: (e) => {
205
- handleMouseLeave(e);
211
+ if (!disabledProp)
212
+ 떠남정리(e);
206
213
  hideTooltipFn();
207
214
  if (!disabledProp && typeof rest.onBlur === 'function')
208
215
  rest.onBlur(e);
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useState, useRef } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Icon from './Icon';
6
6
  import { formatTimeHM } from '../utils/format';
7
+ import { getTodayKst } from '../utils/date';
7
8
  import './Calendar.css';
8
9
  import { pressable } from '../utils/pressable';
9
10
  function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate(); }
@@ -12,11 +13,13 @@ function firstDayOfMonth(year, month) { return new Date(year, month, 1).getDay()
12
13
  export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDayClick, }) {
13
14
  const { t } = useTranslation();
14
15
  const DOW = useMemo(() => t('calendar.dow', { returnObjects: true }), [t]);
15
- const [year, setYear] = useState(new Date().getFullYear());
16
- const [month, setMonth] = useState(new Date().getMonth());
16
+ /* '오늘' 은 KST 의 날 — 방문 날짜가 KST 달력(toKstYmd)이라, 기기 달력으로 열면 해외 기기에서 오늘 방문이 안 보이는 달에 있고
17
+ 오늘 표시가 다른 날에 붙었다(해외 기기 공식 지원) */
18
+ const 오늘Ymd = getTodayKst();
19
+ const [year, setYear] = useState(() => Number(오늘Ymd.slice(0, 4)));
20
+ const [month, setMonth] = useState(() => Number(오늘Ymd.slice(5, 7)) - 1);
17
21
  const [dragOverDate, setDragOverDate] = useState(null);
18
22
  const dragVisitId = useRef(null);
19
- const today = new Date();
20
23
  const visitsByDate = useMemo(() => {
21
24
  const map = {};
22
25
  visits.forEach((v) => {
@@ -51,15 +54,15 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
51
54
  else
52
55
  setMonth(m => m + 1); }, [month]);
53
56
  const fmtDate = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
54
- const isToday = (d) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === d;
55
- return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { type: "button", className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { type: "button", className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
57
+ const isToday = (d) => fmtDate(d) === 오늘Ymd;
58
+ return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { type: "button", className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", "aria-live": "polite", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { type: "button", className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
56
59
  if (d === null)
57
60
  return _jsx("div", { className: "calendar-cell empty" }, `empty-${i}`);
58
61
  const day = d;
59
62
  const dateKey = fmtDate(day);
60
63
  const dayVisits = visitsByDate[dateKey] ?? [];
61
64
  const isDragOver = dragOverDate === dateKey;
62
- return (_jsxs("div", { className: `calendar-cell${isToday(day) ? ' today' : ''}${isDragOver ? ' drag-over' : ''}${onDayClick ? ' calendar-cell--clickable' : ''}`, role: onDayClick ? 'button' : undefined, tabIndex: onDayClick ? 0 : undefined, "aria-label": onDayClick ? t('calendar.addOnDate', { date: dateKey }) : undefined, onClick: onDayClick ? () => onDayClick(dateKey) : undefined,
65
+ return (_jsxs("div", { className: `calendar-cell${isToday(day) ? ' today' : ''}${isDragOver ? ' drag-over' : ''}${onDayClick ? ' calendar-cell--clickable' : ''}`, role: onDayClick ? 'button' : undefined, tabIndex: onDayClick ? 0 : undefined, "aria-label": onDayClick ? t('calendar.addOnDate', { date: dateKey }) : undefined, "aria-current": isToday(day) ? 'date' : undefined, onClick: onDayClick ? () => onDayClick(dateKey) : undefined,
63
66
  /* `<div role="button">` 은 브라우저가 Enter·Space 를 눌림으로 바꿔 주지 않는다 */
64
67
  onKeyDown: onDayClick ? (e) => {
65
68
  /* 칸 안의 방문 표시에서 누른 Enter 가 올라온 것이면 넘긴다 — 마우스 click 은 표시가
@@ -9,6 +9,7 @@ import { cx } from '../utils/cx';
9
9
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
10
  import { useTranslation } from 'react-i18next';
11
11
  import { createPortal } from 'react-dom';
12
+ import { getTodayKst } from '../utils/date';
12
13
  import Calendar from 'lucide-react/dist/esm/icons/calendar.mjs';
13
14
  import ChevronLeft from 'lucide-react/dist/esm/icons/chevron-left.mjs';
14
15
  import ChevronRight from 'lucide-react/dist/esm/icons/chevron-right.mjs';
@@ -55,7 +56,8 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
55
56
  영어 화면의 날짜 고르개만 한국어 요일이 떴다 — 같은 저장소의 `Calendar` 는
56
57
  진작 `calendar.dow` 를 쓰고 있었고, 사전에는 영어(`Sun`·`Mon`…)도 들어 있었다. */
57
58
  const DOW = useMemo(() => t('calendar.dow', { returnObjects: true }), [t]);
58
- const today = new Date();
59
+ // 오늘 표시·열 때 포커스는 KST 의 날 — 기기 달력이면 해외 기기에서 하루 어긋나 min=오늘 달력이 앞 달로 열렸다
60
+ const 오늘ISO = getTodayKst();
59
61
  const { viewY, viewM, prevMonth, nextMonth, setViewYM } = useCalendarMonth(value);
60
62
  const computeStyle = useCallback((rect) => {
61
63
  const spaceBelow = window.innerHeight - rect.bottom;
@@ -122,7 +124,6 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
122
124
  useEffect(() => {
123
125
  if (!open)
124
126
  return;
125
- const 오늘ISO = toISO(today.getFullYear(), today.getMonth(), today.getDate());
126
127
  const 첫자리 = value && value.slice(0, 7) === `${viewY}-${String(viewM + 1).padStart(2, '0')}` ? +value.slice(8)
127
128
  : 오늘ISO.slice(0, 7) === `${viewY}-${String(viewM + 1).padStart(2, '0')}` ? +오늘ISO.slice(8)
128
129
  : 1;
@@ -195,9 +196,13 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
195
196
  e.preventDefault();
196
197
  }, [viewY, viewM, 활성일, 옮기기]);
197
198
  const selectDay = useCallback((d) => {
199
+ if (disabled)
200
+ return; // 열린 채 disabled 가 되면 남은 달력에서 고른 날이 그대로 나갔다
198
201
  onChange(toISO(viewY, viewM, d));
199
202
  setOpen(false);
200
- }, [viewY, viewM, onChange, setOpen]);
203
+ }, [viewY, viewM, onChange, setOpen, disabled]);
204
+ useEffect(() => { if (disabled)
205
+ setOpen(false); }, [disabled, setOpen]);
201
206
  const isDisabledDay = (d) => {
202
207
  const iso = toISO(viewY, viewM, d);
203
208
  if (min && iso < min)
@@ -233,7 +238,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
233
238
  return _jsx("div", {}, `e${i}`);
234
239
  const iso = toISO(viewY, viewM, d);
235
240
  const isSelected = iso === value;
236
- const isToday = iso === toISO(today.getFullYear(), today.getMonth(), today.getDate());
241
+ const isToday = iso === 오늘ISO;
237
242
  const dis = isDisabledDay(d);
238
243
  const col = (i % 7);
239
244
  return (_jsx("button", { type: "button", disabled: dis, "data-day": d, tabIndex: d === 탭자리 ? 0 : -1, "aria-current": isToday ? 'date' : undefined, onClick: () => !dis && selectDay(d), className: cx('date-picker__day caption-1', isSelected && 'date-picker__day--selected', isToday && !isSelected && 'date-picker__day--today', dis && 'date-picker__day--disabled', col === 0 && 'date-picker__day--sun', col === 6 && 'date-picker__day--sat'), children: d }, d));
@@ -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 에서 직접 들었다.
@@ -18,6 +18,8 @@ export interface DropdownProps {
18
18
  className?: string;
19
19
  /** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용 */
20
20
  ariaLabel?: string;
21
+ /** 트리거를 설명할 요소 id(들) — 오류 문구 등. 고른 값은 이것과 함께 설명으로 따로 읽힌다. */
22
+ ariaDescribedBy?: string;
21
23
  }
22
24
  /** @public */
23
- export default function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel }: DropdownProps): import("react").JSX.Element;
25
+ export default function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel, ariaDescribedBy }: DropdownProps): import("react").JSX.Element;
@@ -7,10 +7,11 @@ import { useTranslation } from 'react-i18next';
7
7
  import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
8
8
  import './Dropdown.css';
9
9
  /** @public */
10
- export default function Dropdown({ options, value, onChange, placeholder, disabled = false, block = false, size = 48, error = false, className = '', ariaLabel }) {
10
+ export default function Dropdown({ options, value, onChange, placeholder, disabled = false, block = false, size = 48, error = false, className = '', ariaLabel, ariaDescribedBy }) {
11
11
  const { t } = useTranslation();
12
12
  const resolvedPlaceholder = placeholder ?? t('dropdown.placeholder', '선택');
13
13
  const listId = useId();
14
+ const valueId = useId();
14
15
  const containerRef = useRef(null);
15
16
  const computeStyle = useCallback((rect) => {
16
17
  // 아래쪽 공간이 메뉴 최대 높이(260px, Dropdown.css)보다 부족하고 위쪽에 공간이
@@ -62,11 +63,15 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
62
63
  const toggle = useCallback(() => { if (!disabled)
63
64
  rawToggle(); }, [disabled, rawToggle]);
64
65
  const select = useCallback((opt) => {
65
- if (opt.disabled)
66
+ // 열린 채 disabled 가 되면(저장 중 잠금) 남은 메뉴에서 고른 것이 그대로 onChange 로 나갔다
67
+ if (opt.disabled || disabled)
66
68
  return;
67
69
  onChange?.(opt.value);
68
70
  setOpen(false);
69
- }, [onChange, setOpen]);
71
+ }, [onChange, setOpen, disabled]);
72
+ // 막히면 열린 메뉴도 닫는다
73
+ useEffect(() => { if (disabled)
74
+ setOpen(false); }, [disabled, setOpen]);
70
75
  const handleKeyDown = useCallback((e) => {
71
76
  if (disabled)
72
77
  return;
@@ -113,12 +118,12 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
113
118
  // mousedown 에서는 기본 동작(포커스 이동 → 트리거 blur → 메뉴 닫힘)만 막아,
114
119
  // click 이 도달하기 전에 메뉴가 사라지지 않게 한다.
115
120
  onMouseDown: e => e.preventDefault(), onClick: () => select(opt), onMouseEnter: () => setActiveIdx(i), disabled: opt.disabled, children: [_jsx("span", { className: "dropdown__option-check", children: opt.value === value && (_jsx("svg", { viewBox: "0 0 14 14", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "2 7 5.5 10.5 12 3.5" }) })) }), opt.label] }, opt.value))) })) : null;
116
- return (_jsxs("div", { ref: containerRef, className: cx('dropdown', `dropdown--s${size}`, open ? 'dropdown--open' : '', block ? 'dropdown--block' : '', error ? 'dropdown--error' : '', className), onKeyDown: handleKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "dropdown__trigger", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && activeIdx >= 0 ? `${listId}-option-${activeIdx}` : undefined, "aria-label": ariaLabel, "aria-invalid": error || undefined, disabled: disabled, onClick: toggle,
121
+ return (_jsxs("div", { ref: containerRef, className: cx('dropdown', `dropdown--s${size}`, open ? 'dropdown--open' : '', block ? 'dropdown--block' : '', error ? 'dropdown--error' : '', className), onKeyDown: handleKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "dropdown__trigger", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && activeIdx >= 0 ? `${listId}-option-${activeIdx}` : undefined, "aria-label": ariaLabel, "aria-describedby": [ariaLabel && selected ? valueId : '', ariaDescribedBy ?? ''].filter(Boolean).join(' ') || undefined, "aria-invalid": error || undefined, disabled: disabled, onClick: toggle,
117
122
  /* Tab 으로 포커스가 떠나면 닫는다 — 포커스는 트리거에 두고 aria-activedescendant 로 가리키는 목록이라, 떠난 뒤
118
123
  열린 채 남으면 화살표·Enter 가 더는 메뉴로 가지 않는 죽은 메뉴가 된다. 항목·메뉴를 누르는 것은 위에서
119
124
  mousedown 기본 동작을 막아 blur 가 나지 않는다. */
120
125
  onBlur: e => { if (open && !menuRef.current?.contains(e.relatedTarget))
121
- setOpen(false); }, children: [_jsx("span", { className: `dropdown__trigger-label body-2${!selected ? ' dropdown__trigger-label--placeholder' : ''}`, children: selected ? selected.label : resolvedPlaceholder }), _jsx("svg", { className: "dropdown__chevron", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "4 6 8 10 12 6" }) })] }), typeof document !== 'undefined' && createPortal(menu,
126
+ setOpen(false); }, children: [_jsx("span", { id: valueId, className: `dropdown__trigger-label body-2${!selected ? ' dropdown__trigger-label--placeholder' : ''}`, children: selected ? selected.label : resolvedPlaceholder }), _jsx("svg", { className: "dropdown__chevron", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "4 6 8 10 12 6" }) })] }), typeof document !== 'undefined' && createPortal(menu,
122
127
  // 코어 CSS를 .ihabdevteam-core-scope 로 스코프한 소비자(플레이그라운드 등)에서도
123
128
  // body로 portal된 메뉴가 스타일을 잃지 않도록, 가까운 scope 안으로 portal
124
129
  // createPortal의 target은 렌더링 시점에 동기적으로 결정되어야 하므로 ref 읽기를 effect로 옮길 수 없다
@@ -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;
@@ -2,6 +2,11 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import './Loader.css';
5
+ /** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
6
+ const 보이지않게 = {
7
+ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
8
+ clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
9
+ };
5
10
  /** @public */
6
11
  export default function Loader(props) {
7
12
  const { t } = useTranslation();
@@ -13,5 +18,5 @@ export default function Loader(props) {
13
18
  return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: { width: `${pct}%` } }) }));
14
19
  }
15
20
  const dim = typeof size === 'number' ? `${size}px` : size;
16
- return (_jsx("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] }) }));
21
+ return (_jsxs("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: [_jsx("span", { style: 보이지않게, children: name }), _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] })] }));
17
22
  }