@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.
- package/CHANGELOG.md +44 -0
- package/dist/components.cjs.js +176 -176
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +61 -21
- package/dist/packages/core/src/components/BarChart.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +4 -3
- package/dist/packages/core/src/components/BottomNav.js +4 -0
- package/dist/packages/core/src/components/Button.js +10 -4
- package/dist/packages/core/src/components/DatePicker.js +5 -1
- package/dist/packages/core/src/components/Dialog.js +3 -1
- package/dist/packages/core/src/components/Dropdown.js +6 -2
- package/dist/packages/core/src/components/FileUpload.js +3 -1
- package/dist/packages/core/src/components/FilterPanel.js +3 -2
- package/dist/packages/core/src/components/PanelRow.js +5 -0
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
- package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/Popup.js +5 -2
- package/dist/packages/core/src/components/TextField.js +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +48 -1
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
- package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
- package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
- package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
- package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
- package/dist/packages/core/src/components/barChartData.d.ts +3 -5
- package/dist/packages/core/src/components/barChartData.js +7 -3
- package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
- package/dist/packages/core/src/components/barChartTypes.js +27 -13
- package/dist/packages/core/src/components.d.ts +1 -0
- package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
- package/dist/packages/core/src/hooks/focusReturn.js +27 -0
- package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
- package/dist/packages/core/src/utils/date.d.ts +18 -0
- package/dist/packages/core/src/utils/date.js +23 -0
- package/dist/packages/core/src/utils/format.js +2 -1
- package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
- package/dist/packages/core/src/utils/imageConfig.js +34 -5
- package/dist/packages/core/src/utils/josa.js +2 -0
- package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
- package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
- package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
- package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
- package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
- package/dist/packages/core/src/utils/ttsConfig.js +2 -1
- package/dist/packages/core/src/utils/wordContrast.js +6 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- 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.
|
|
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
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
-
/** 전달하면 저장
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 && !
|
|
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)
|
|
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
|
}
|