@ihabdevteam/core 0.309.0 → 0.311.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 +80 -0
- package/dist/components.cjs.js +15 -15
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +185 -185
- package/dist/ihabdevteam-core.d.ts +120 -10
- package/dist/packages/core/src/components/CountTile.css +4 -0
- package/dist/packages/core/src/components/CountTile.d.ts +11 -1
- package/dist/packages/core/src/components/CountTile.js +2 -2
- package/dist/packages/core/src/components/Popup/Popup.css +12 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +11 -0
- package/dist/packages/core/src/components/Popup/Popup.js +4 -2
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.d.ts +31 -3
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +25 -13
- package/dist/packages/core/src/components/Training/TrainingBlock.css +18 -0
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.d.ts +27 -0
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +65 -0
- package/dist/packages/core/src/components/Training/TrainingStep.d.ts +10 -2
- package/dist/packages/core/src/components/Training/TrainingStep.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepCard.d.ts +4 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.d.ts +4 -1
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.d.ts +4 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.d.ts +4 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +2 -2
- package/dist/packages/core/src/components/Training/trainingStepOrderListTypes.d.ts +3 -0
- package/dist/packages/core/src/components/Training/trainingStepOrderListTypes.js +0 -4
- package/dist/packages/core/src/components.d.ts +3 -1
- package/dist/packages/core/src/components.js +1 -0
- package/dist/packages/core/src/i18n/locales/en.json +0 -1
- package/dist/packages/core/src/i18n/locales/ko.json +0 -1
- package/dist/packages/core/src/utils/blockGrid.d.ts +16 -0
- package/dist/packages/core/src/utils/blockGrid.js +28 -0
- package/dist/packages/core/src/utils.d.ts +1 -0
- package/dist/packages/core/src/utils.js +1 -0
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -304,6 +304,9 @@ export declare interface BadgeProps {
|
|
|
304
304
|
/** @public */
|
|
305
305
|
export declare type BadgeType = 'bar' | 'dot';
|
|
306
306
|
|
|
307
|
+
/** 장수와 한 줄 최대로 칸 수 — 한 줄에 다 들면 그대로, 접히면 고르게 */
|
|
308
|
+
export declare function balancedColumns(count: number, perRow: number): number;
|
|
309
|
+
|
|
307
310
|
/**
|
|
308
311
|
* 막대차트 섹션 — 제목 + 구간 네비게이션(이전/다음 + 드롭다운) + 월/주 토글 + 2계열 막대차트.
|
|
309
312
|
* - 월(month): 월 드롭다운 + 주별 막대(1주~5주)
|
|
@@ -1352,6 +1355,21 @@ export declare interface ColorPickerProps {
|
|
|
1352
1355
|
/** @public */
|
|
1353
1356
|
export declare type ColorPickerSize = 32 | 40 | 48;
|
|
1354
1357
|
|
|
1358
|
+
/**
|
|
1359
|
+
* **실행 창의 카드 줄 — 접힐 때만 고르게**(말귀 요청 (110), 사용자 지시 2026-10-10
|
|
1360
|
+
* *"오버라이드하거나 반복적으로 주입하는 건 코어에 요청해서 공통으로"*).
|
|
1361
|
+
*
|
|
1362
|
+
* 정사각 카드 다섯이 넷+하나로 접혀 마지막 한 장이 빈 판 가운데 떠 보였다(사용자 보고). 앱이 창 최대
|
|
1363
|
+
* 1140 · 좌우 40 · 안쪽 90(실측) · 카드 200/190 을 **베껴 들고** 칸 수를 세어 `--run-cols` 로 주입했다 —
|
|
1364
|
+
* 코어가 하나라도 바꾸면 어긋난다. 여기서는 **아무 치수도 안 벤다**: 그리드가 제 폭과 첫 카드의 폭을
|
|
1365
|
+
* 재서 센다(`TrainingBlockGrid`). 순수 셈만 여기 둔다.
|
|
1366
|
+
*
|
|
1367
|
+
* 규칙(사용자: *"무조건이 아니라 개행될 때만"*): 한 줄에 다 들면 그대로, 안 들어 접히면 줄 수를
|
|
1368
|
+
* 먼저 정하고(`ceil(n / 한줄)`) 고르게 나눈다 — 한 줄 넷이면 5 → 3+2 · 6 → 3+3 · 7 → 4+3.
|
|
1369
|
+
*/
|
|
1370
|
+
/** 이 폭에 카드가 한 줄에 몇 장 드나(최소 1) */
|
|
1371
|
+
export declare function columnsFitting(width: number, cardWidth: number, gap: number): number;
|
|
1372
|
+
|
|
1355
1373
|
/**
|
|
1356
1374
|
* 푸터의 사업자 정보 한 줄.
|
|
1357
1375
|
* 좁은 화면에서는 세로로 쌓이고, 넓어지면 구분선과 함께 한 줄로 붙는다.
|
|
@@ -1651,7 +1669,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1651
1669
|
*
|
|
1652
1670
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1653
1671
|
*/
|
|
1654
|
-
export declare const CORE_VERSION = "0.
|
|
1672
|
+
export declare const CORE_VERSION = "0.311.0";
|
|
1655
1673
|
|
|
1656
1674
|
/** @public */
|
|
1657
1675
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -1739,7 +1757,7 @@ export declare type CoreScopeStorageConfig = {
|
|
|
1739
1757
|
* 자리와 같은 상수를 쓴다(지나가다 스치는 것에 번쩍이지 않게).
|
|
1740
1758
|
* @public
|
|
1741
1759
|
*/
|
|
1742
|
-
export declare function CountTile({ icon, count, title, plan, tooltip, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): JSX.Element;
|
|
1760
|
+
export declare function CountTile({ icon, count, title, plan, tooltip, hoverAccent, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): JSX.Element;
|
|
1743
1761
|
|
|
1744
1762
|
/** @public */
|
|
1745
1763
|
export declare interface CountTileProps {
|
|
@@ -1768,6 +1786,16 @@ export declare interface CountTileProps {
|
|
|
1768
1786
|
plan?: TrainingPlan;
|
|
1769
1787
|
/** 호버 툴팁을 끈다(기본 켬) — 부르는 쪽이 제 툴팁을 띄우는 자리용 */
|
|
1770
1788
|
tooltip?: boolean;
|
|
1789
|
+
/**
|
|
1790
|
+
* 호버 테두리를 **본색(`--color-main`)** 으로(말귀 요청 (109), 사용자 지시 2026-10-10
|
|
1791
|
+
* *"오버라이드하거나 반복적으로 주입하는 건 코어에 요청해서 공통으로"*). 기본은 `--color-lightGray`.
|
|
1792
|
+
*
|
|
1793
|
+
* 어디서 켜는지는 **부르는 쪽이 정한다**(훈련 카드 넷 안에서는 — 그건 앱의 사정이다).
|
|
1794
|
+
* `TrainingCard` 가 제 안의 타일에 대신 넘기는 꼴로 안 둔 까닭: 자리가 둘이 되어 한쪽만
|
|
1795
|
+
* 바뀌는 날이 온다((105) 의 `--clickable`/`--interactive` 와 같은 결). 반응하지 않는 타일
|
|
1796
|
+
* (`--interactive` 가 안 붙는 것)에는 켜도 아무 일이 없다 — 호버 규칙 자체가 그쪽에만 산다.
|
|
1797
|
+
*/
|
|
1798
|
+
hoverAccent?: boolean;
|
|
1771
1799
|
className?: string;
|
|
1772
1800
|
/** 클릭 핸들러 — 설정 시 버튼처럼 동작 */
|
|
1773
1801
|
onClick?: (e: MouseEvent_2) => void;
|
|
@@ -5094,6 +5122,17 @@ export declare interface PopupProps {
|
|
|
5094
5122
|
};
|
|
5095
5123
|
/** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
|
|
5096
5124
|
footerClassName?: string;
|
|
5125
|
+
/**
|
|
5126
|
+
* 바닥 줄 **위**의 한 줄 안내 띠(말귀 요청 (108), 사용자 지시 2026-10-10). 숙제 창 둘(회원 `info` ·
|
|
5127
|
+
* 매니저 `.hwd-foot`)이 `.popup-footer` 에 글줄을 싣느라 각자 덧칠했고 **벌써 둘이 달랐다**(한쪽만
|
|
5128
|
+
* 실선). 띠 높이 40 · 위로 숨 · 왼쪽 정렬 — 코어가 하나로 쥔다.
|
|
5129
|
+
*
|
|
5130
|
+
* 바닥 줄(`buttons` · `footer` · `footerStart/End`)과 **따로** 그려진다 — 바닥 줄이 없어도 선다.
|
|
5131
|
+
* 글이 길면 줄을 바꾼다(40 은 최소 높이다). `footerNoteDivider` 로 위에 실선을 둔다.
|
|
5132
|
+
*/
|
|
5133
|
+
footerNote?: ReactNode;
|
|
5134
|
+
/** `footerNote` 위에 실선(기본 없음). 창마다 있고 없고가 갈리던 자리라 **값으로** 고른다. */
|
|
5135
|
+
footerNoteDivider?: boolean;
|
|
5097
5136
|
/**
|
|
5098
5137
|
* 바닥 줄 **왼쪽 덩이** — 보조 단추(아이콘 단추 여럿)가 서는 자리.
|
|
5099
5138
|
*
|
|
@@ -8089,7 +8128,15 @@ export declare interface TooltipTextBoxProps {
|
|
|
8089
8128
|
|
|
8090
8129
|
/** @public */
|
|
8091
8130
|
export declare interface TooltipTrainingProps {
|
|
8092
|
-
|
|
8131
|
+
/**
|
|
8132
|
+
* 담긴 훈련 — **풀에서 찾는** 길. `items` 의 `trainingType`/`itemId` 로 등록된 풀에서 자료를 찾아
|
|
8133
|
+
* 갈래별로 묶는다. 풀에 없는 것(단어 세트 등)은 안 그려진다 → 그때는 `groups`.
|
|
8134
|
+
*/
|
|
8135
|
+
plan?: TrainingPlan;
|
|
8136
|
+
/**
|
|
8137
|
+
* 담긴 훈련 — **자료를 손에 들고 주는** 길((111)). 주면 `plan` 은 안 본다. 빈 묶음은 안 그린다.
|
|
8138
|
+
*/
|
|
8139
|
+
groups?: TrainingTooltipGroup[];
|
|
8093
8140
|
anchorRect?: DOMRect | AnchorRectLike | null;
|
|
8094
8141
|
offset?: number;
|
|
8095
8142
|
onClose?: () => void;
|
|
@@ -8367,6 +8414,33 @@ export declare interface TrainingBlockButtonProps {
|
|
|
8367
8414
|
countLabelOverride?: ReactNode;
|
|
8368
8415
|
}
|
|
8369
8416
|
|
|
8417
|
+
/**
|
|
8418
|
+
* **정사각 카드를 세우는 그리드 — 접힐 때만 줄을 고르게**((110), 2026-10-10).
|
|
8419
|
+
*
|
|
8420
|
+
* 한 줄에 다 들면 그대로, 안 들어 접히면 5 → 3+2 · 6 → 3+3 · 7 → 4+3 로 나누고 **그 칸 수만큼만**
|
|
8421
|
+
* 벌어진다(마지막 줄이 가운데 모여 좌우가 비지 않는다). 앱이 창 치수 넷을 베껴 세던 것을 걷는다 —
|
|
8422
|
+
* 여기는 **제 폭과 첫 카드의 폭을 재서** 센다. 창이 몇이든, 안쪽 여백이 바뀌든 안 베낀다.
|
|
8423
|
+
*
|
|
8424
|
+
* 재는 것은 둘뿐이다: 이 상자의 폭(부모가 주는 폭 — `max-width` 를 걸기 **전** 폭을 재려고 재는 동안
|
|
8425
|
+
* `max-width` 를 푼다), 첫 카드의 폭(코어 `TrainingBlockButton` 정사각 200 · 768~1023 에서 190 —
|
|
8426
|
+
* **CSS 가 정한 값을 그대로 읽으므로 여기 안 적는다**). `ResizeObserver` 가 없는 곳(SSR·jsdom)에서는
|
|
8427
|
+
* 처음 한 번만 잰다.
|
|
8428
|
+
*
|
|
8429
|
+
* 좁은 화면(767 이하)에서는 `max-width` 를 안 건다 — 거기서는 카드가 `row` 로 풀폭이다.
|
|
8430
|
+
* @public
|
|
8431
|
+
*/
|
|
8432
|
+
export declare function TrainingBlockGrid({ gap, count, className, children }: TrainingBlockGridProps): JSX.Element;
|
|
8433
|
+
|
|
8434
|
+
/** @public */
|
|
8435
|
+
export declare interface TrainingBlockGridProps {
|
|
8436
|
+
/** 카드 사이 간격(px). 기본 10 */
|
|
8437
|
+
gap?: number;
|
|
8438
|
+
/** 세울 카드 수 — 자식 수와 다를 수 있을 때(빈 칸 등) 준다. 없으면 자식 수 */
|
|
8439
|
+
count?: number;
|
|
8440
|
+
className?: string;
|
|
8441
|
+
children?: ReactNode;
|
|
8442
|
+
}
|
|
8443
|
+
|
|
8370
8444
|
/** @public */
|
|
8371
8445
|
export declare interface TrainingBlockProps {
|
|
8372
8446
|
type?: TrainingTypeDescriptor;
|
|
@@ -9046,13 +9120,15 @@ export declare function TrainingStepBullet({ state }: {
|
|
|
9046
9120
|
}): JSX.Element;
|
|
9047
9121
|
|
|
9048
9122
|
/** @public */
|
|
9049
|
-
export declare function TrainingStepCard({ index, stepIndex, isLast, title, onDone, onChange, context, selectable, hideLabel, hideAudio, maxCards, playAllGapMs }: TrainingStepCardProps): JSX.Element;
|
|
9123
|
+
export declare function TrainingStepCard({ index, stepIndex, isLast, lockedHint, title, onDone, onChange, context, selectable, hideLabel, hideAudio, maxCards, playAllGapMs }: TrainingStepCardProps): JSX.Element;
|
|
9050
9124
|
|
|
9051
9125
|
/** @public */
|
|
9052
9126
|
export declare type TrainingStepCardProps = {
|
|
9053
9127
|
index?: number;
|
|
9054
9128
|
stepIndex?: number;
|
|
9055
9129
|
isLast?: boolean;
|
|
9130
|
+
/** 잠긴 까닭 한 줄 — `TrainingStep` 으로 그대로 넘긴다. (107): 안 주면 안 선다. (99) 때는 이 겉싸개가 안 넘겨 맨 `TrainingStep` 에서만 줄 수 있었다 */
|
|
9131
|
+
lockedHint?: ReactNode | null;
|
|
9056
9132
|
title?: string;
|
|
9057
9133
|
onDone?: (payload?: unknown) => void;
|
|
9058
9134
|
onChange?: (payload?: unknown) => void;
|
|
@@ -9086,13 +9162,15 @@ export declare type TrainingStepCardProps = {
|
|
|
9086
9162
|
};
|
|
9087
9163
|
|
|
9088
9164
|
/** @public */
|
|
9089
|
-
export declare function TrainingStepConfirm({ index, stepIndex, isLast, title, useTitleFaded, onDone, onChange, answer, wrongAnswers, choices: propChoices, multiSelect, correctCount, useDisabledCovered, choiceIcons, shuffleChoices }: TrainingStepConfirmProps): JSX.Element;
|
|
9165
|
+
export declare function TrainingStepConfirm({ index, stepIndex, isLast, lockedHint, title, useTitleFaded, onDone, onChange, answer, wrongAnswers, choices: propChoices, multiSelect, correctCount, useDisabledCovered, choiceIcons, shuffleChoices }: TrainingStepConfirmProps): JSX.Element;
|
|
9090
9166
|
|
|
9091
9167
|
/** @public */
|
|
9092
9168
|
export declare type TrainingStepConfirmProps = {
|
|
9093
9169
|
index?: number;
|
|
9094
9170
|
stepIndex?: number;
|
|
9095
9171
|
isLast?: boolean;
|
|
9172
|
+
/** 잠긴 까닭 한 줄 — `TrainingStep` 으로 그대로 넘긴다. (107): 안 주면 안 선다. (99) 때는 이 겉싸개가 안 넘겨 맨 `TrainingStep` 에서만 줄 수 있었다 */
|
|
9173
|
+
lockedHint?: ReactNode | null;
|
|
9096
9174
|
title?: string;
|
|
9097
9175
|
useTitleFaded?: boolean;
|
|
9098
9176
|
onDone?: (payload?: TrainingPayload) => void;
|
|
@@ -9120,13 +9198,15 @@ export declare type TrainingStepConfirmProps = {
|
|
|
9120
9198
|
};
|
|
9121
9199
|
|
|
9122
9200
|
/** @public */
|
|
9123
|
-
export declare function TrainingStepConfirmWithText({ index, stepIndex, isLast, title, onDone, onChange, context, demoSelected }: TrainingStepConfirmWithTextProps): JSX.Element;
|
|
9201
|
+
export declare function TrainingStepConfirmWithText({ index, stepIndex, isLast, lockedHint, title, onDone, onChange, context, demoSelected }: TrainingStepConfirmWithTextProps): JSX.Element;
|
|
9124
9202
|
|
|
9125
9203
|
/** @public */
|
|
9126
9204
|
export declare type TrainingStepConfirmWithTextProps = {
|
|
9127
9205
|
index?: number;
|
|
9128
9206
|
stepIndex?: number;
|
|
9129
9207
|
isLast?: boolean;
|
|
9208
|
+
/** 잠긴 까닭 한 줄 — `TrainingStep` 으로 그대로 넘긴다. (107): 안 주면 안 선다. (99) 때는 이 겉싸개가 안 넘겨 맨 `TrainingStep` 에서만 줄 수 있었다 */
|
|
9209
|
+
lockedHint?: ReactNode | null;
|
|
9130
9210
|
title?: string;
|
|
9131
9211
|
onDone?: (payload?: TrainingPayload) => void;
|
|
9132
9212
|
onChange?: (payload?: TrainingPayload) => void;
|
|
@@ -9175,13 +9255,15 @@ export declare type TrainingStepListenProps = {
|
|
|
9175
9255
|
tailMs?: number;
|
|
9176
9256
|
};
|
|
9177
9257
|
|
|
9178
|
-
export declare function TrainingStepOrderList({ index, stepIndex, isLast, title, onDone, onChange, context, }: TrainingStepOrderListProps): JSX.Element;
|
|
9258
|
+
export declare function TrainingStepOrderList({ index, stepIndex, isLast, lockedHint, title, onDone, onChange, context, }: TrainingStepOrderListProps): JSX.Element;
|
|
9179
9259
|
|
|
9180
9260
|
/** @public */
|
|
9181
9261
|
export declare interface TrainingStepOrderListProps {
|
|
9182
9262
|
index?: number;
|
|
9183
9263
|
stepIndex?: number;
|
|
9184
9264
|
isLast?: boolean;
|
|
9265
|
+
/** 잠긴 까닭 한 줄 — `TrainingStep` 으로 그대로 넘긴다. (107): 안 주면 안 선다. (99) 때는 이 겉싸개가 안 넘겨 맨 `TrainingStep` 에서만 줄 수 있었다 */
|
|
9266
|
+
lockedHint?: ReactNode | null;
|
|
9185
9267
|
title?: string;
|
|
9186
9268
|
onDone?: (res: ChangeResult) => void;
|
|
9187
9269
|
onChange?: (res: ChangeResult) => void;
|
|
@@ -9203,8 +9285,16 @@ export declare type TrainingStepProps = {
|
|
|
9203
9285
|
* **왜 아직 못 누르는지 알리는 한 줄** — 잠긴 단계 **바로 다음 차례**에만 선다
|
|
9204
9286
|
* (말귀 요청 (99), 2026-10-08).
|
|
9205
9287
|
*
|
|
9206
|
-
*
|
|
9207
|
-
*
|
|
9288
|
+
* **주는 곳에서만 선다 — 안 주면 아무것도 안 선다**(말귀 요청 (107), 사용자 지시 2026-10-10:
|
|
9289
|
+
* 실행 창의 *"다 들으면 고를 수 있어요."* 를 보고 *"누가 추가했어, 제거해"*). 0.302.0 ~ 0.309.0
|
|
9290
|
+
* 에는 안 줘도 그 말이 기본으로 섰는데, **앱에서는 끌 길이 없었다** — 그 단계는 코어 러너가
|
|
9291
|
+
* 세워서 앱이 이 prop 을 만질 자리가 없다(앱 저장소 0건). 켜고 싶으면 **앱 사전에** 그 말을 두고
|
|
9292
|
+
* `lockedHint={t(…)}` 로 준다 — 코어 열쇠 `training.answerLockedHint` 는 0.311.0 에서 걷었다(부르는
|
|
9293
|
+
* 곳 0·켤 자리 0 인 열쇠는 아직 안 쓰인 길을 미리 내 둔 것이다). `null` 은 안 준 것과 같다.
|
|
9294
|
+
*
|
|
9295
|
+
* (99) 의 낭독기 쪽 까닭(단추가 진짜 `disabled` 라 초점이 안 가 까닭을 들을 길이 없다)은
|
|
9296
|
+
* 그대로 참이다 — 주면 여전히 `role="status"` 로 선다. 보이는 글 없이 그 길만 지키려면
|
|
9297
|
+
* 부르는 쪽이 시각적으로 숨긴 노드를 주면 된다. 코어가 몰래 세우지는 않는다.
|
|
9208
9298
|
*/
|
|
9209
9299
|
lockedHint?: ReactNode | null;
|
|
9210
9300
|
};
|
|
@@ -9253,7 +9343,27 @@ export declare interface TrainingTimeLogEntry {
|
|
|
9253
9343
|
declare type TrainingTimeLogger = (entry: TrainingTimeLogEntry) => Promise<void> | void;
|
|
9254
9344
|
|
|
9255
9345
|
/** @public */
|
|
9256
|
-
export declare function TrainingTooltip({ plan, anchorRect, offset, onClose }: TooltipTrainingProps): JSX.Element | null;
|
|
9346
|
+
export declare function TrainingTooltip({ plan, groups: givenGroups, anchorRect, offset, onClose }: TooltipTrainingProps): JSX.Element | null;
|
|
9347
|
+
|
|
9348
|
+
/**
|
|
9349
|
+
* **자료를 손에 들고 주는 길**(말귀 요청 (111), 사용자 지시 2026-10-10). 갈래 이름표 하나에 그 갈래의
|
|
9350
|
+
* `trainingData` 들 — `plan` 처럼 **풀에서 찾지 않는다.** 단어 숙제 세트는 문장 풀에 없어 `plan` 으로는
|
|
9351
|
+
* 못 그렸고, 앱이 `tooltip-training__*` 클래스를 **빌려** 같은 모양을 손으로 짰다. 같은 상자를 쓴다.
|
|
9352
|
+
* @public
|
|
9353
|
+
*/
|
|
9354
|
+
export declare interface TrainingTooltipGroup {
|
|
9355
|
+
/** 묶음 머리글 — 갈래 이름 등 */
|
|
9356
|
+
label: ReactNode;
|
|
9357
|
+
/** 묶음 열쇠(React key·갈래 아이콘용). 없으면 차례로 */
|
|
9358
|
+
key?: string;
|
|
9359
|
+
/** 블록 단추의 `type`(아이콘·갈래 이름). 없으면 아이콘 없이 */
|
|
9360
|
+
type?: {
|
|
9361
|
+
key?: string;
|
|
9362
|
+
label?: string;
|
|
9363
|
+
icon?: string;
|
|
9364
|
+
};
|
|
9365
|
+
items: TrainingItem[];
|
|
9366
|
+
}
|
|
9257
9367
|
|
|
9258
9368
|
/** @public */
|
|
9259
9369
|
export declare interface TrainingType {
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
background: var(--color-white);
|
|
35
35
|
border-color: var(--color-lightGray);
|
|
36
36
|
}
|
|
37
|
+
/* 본색 테두리(hoverAccent) — 호버 규칙 위에 색만 얹는다. `--interactive` 가 없으면 이 클래스도 안 붙으니
|
|
38
|
+
반응 안 하는 타일에는 영영 안 닿는다((109), 2026-10-10). 앱이 `.training-card__types .count-tile--interactive:hover`
|
|
39
|
+
로 덮던 한 줄이 이것이다. */
|
|
40
|
+
.count-tile--accent:hover { border-color: var(--color-main); }
|
|
37
41
|
.count-tile--clickable { cursor: pointer; }
|
|
38
42
|
|
|
39
43
|
/* 모바일 — 기본 60×60. 72px일 땐 카드 안에 4개가 못 들어가 3+1로 어정쩡하게 갈라졌다.
|
|
@@ -28,6 +28,16 @@ export interface CountTileProps {
|
|
|
28
28
|
plan?: TrainingPlan;
|
|
29
29
|
/** 호버 툴팁을 끈다(기본 켬) — 부르는 쪽이 제 툴팁을 띄우는 자리용 */
|
|
30
30
|
tooltip?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* 호버 테두리를 **본색(`--color-main`)** 으로(말귀 요청 (109), 사용자 지시 2026-10-10
|
|
33
|
+
* *"오버라이드하거나 반복적으로 주입하는 건 코어에 요청해서 공통으로"*). 기본은 `--color-lightGray`.
|
|
34
|
+
*
|
|
35
|
+
* 어디서 켜는지는 **부르는 쪽이 정한다**(훈련 카드 넷 안에서는 — 그건 앱의 사정이다).
|
|
36
|
+
* `TrainingCard` 가 제 안의 타일에 대신 넘기는 꼴로 안 둔 까닭: 자리가 둘이 되어 한쪽만
|
|
37
|
+
* 바뀌는 날이 온다((105) 의 `--clickable`/`--interactive` 와 같은 결). 반응하지 않는 타일
|
|
38
|
+
* (`--interactive` 가 안 붙는 것)에는 켜도 아무 일이 없다 — 호버 규칙 자체가 그쪽에만 산다.
|
|
39
|
+
*/
|
|
40
|
+
hoverAccent?: boolean;
|
|
31
41
|
className?: string;
|
|
32
42
|
/** 클릭 핸들러 — 설정 시 버튼처럼 동작 */
|
|
33
43
|
onClick?: (e: MouseEvent) => void;
|
|
@@ -42,4 +52,4 @@ export interface CountTileProps {
|
|
|
42
52
|
* 자리와 같은 상수를 쓴다(지나가다 스치는 것에 번쩍이지 않게).
|
|
43
53
|
* @public
|
|
44
54
|
*/
|
|
45
|
-
export default function CountTile({ icon, count, title, plan, tooltip, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): import("react").JSX.Element;
|
|
55
|
+
export default function CountTile({ icon, count, title, plan, tooltip, hoverAccent, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): import("react").JSX.Element;
|
|
@@ -15,7 +15,7 @@ const 건너가는틈_MS = 200;
|
|
|
15
15
|
* 자리와 같은 상수를 쓴다(지나가다 스치는 것에 번쩍이지 않게).
|
|
16
16
|
* @public
|
|
17
17
|
*/
|
|
18
|
-
export default function CountTile({ icon, count, title, plan, tooltip = true, className = '', onClick, onMouseEnter, onMouseLeave }) {
|
|
18
|
+
export default function CountTile({ icon, count, title, plan, tooltip = true, hoverAccent = false, className = '', onClick, onMouseEnter, onMouseLeave }) {
|
|
19
19
|
/**
|
|
20
20
|
* **반응하는 타일이면 반응하는 것처럼 보인다**(말귀 요청 (105), 사용자 보고 2026-10-10).
|
|
21
21
|
*
|
|
@@ -26,7 +26,7 @@ export default function CountTile({ icon, count, title, plan, tooltip = true, cl
|
|
|
26
26
|
* 지킨다 — 누르는 타일과 얹는 타일이 **같은 호버**를 쓰고 커서만 갈린다.
|
|
27
27
|
*/
|
|
28
28
|
const 반응하나 = Boolean(onClick) || Boolean(onMouseEnter) || (tooltip && Boolean(plan));
|
|
29
|
-
const cls = cx('count-tile', 반응하나 && 'count-tile--interactive', onClick && 'count-tile--clickable', className);
|
|
29
|
+
const cls = cx('count-tile', 반응하나 && 'count-tile--interactive', 반응하나 && hoverAccent && 'count-tile--accent', onClick && 'count-tile--clickable', className);
|
|
30
30
|
const ref = useRef(null);
|
|
31
31
|
const [anchorRect, setAnchorRect] = useState(null);
|
|
32
32
|
const 늦춤 = useRef(null);
|
|
@@ -99,6 +99,18 @@
|
|
|
99
99
|
/* 좁으면 **줄을 바꾼다.** 안 바꾸면 단추 셋(120×3 + 사이 = 408px)이 353px 바닥에 그대로 서서
|
|
100
100
|
마지막 단추의 오른쪽 55px 이 잘렸다 — 잘린 쪽은 눌리지도 않는다(말귀 375px 채점 창 실측). */
|
|
101
101
|
.popup-footer { padding: 0 20px 20px; display:flex; flex-wrap: wrap; gap:8px; justify-content:center; align-items:center }
|
|
102
|
+
/* 바닥 줄 **위**의 한 줄 안내 띠((108), 2026-10-10) — 최소 40 · 위로 숨 · 왼쪽 정렬. 본문 `padding-bottom: 20`
|
|
103
|
+
이 "위로 숨" 이고, 띠는 바닥 줄과 같은 좌우 20 을 쓴다. 숙제 창 둘이 각자 덧칠하던 규칙 — 실선은 값으로 고른다. */
|
|
104
|
+
.popup-footer-note {
|
|
105
|
+
min-height: 40px;
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
padding: 0 20px;
|
|
109
|
+
text-align: left;
|
|
110
|
+
color: var(--color-gray);
|
|
111
|
+
flex-shrink: 0;
|
|
112
|
+
}
|
|
113
|
+
.popup-footer-note--divider { border-top: 1px solid var(--color-darkWhite); }
|
|
102
114
|
/* 바닥 배치(footerLayout) */
|
|
103
115
|
.popup-footer--fill > * { flex: 1 }
|
|
104
116
|
.popup-footer--between {
|
|
@@ -52,6 +52,17 @@ export interface PopupProps {
|
|
|
52
52
|
};
|
|
53
53
|
/** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
|
|
54
54
|
footerClassName?: string;
|
|
55
|
+
/**
|
|
56
|
+
* 바닥 줄 **위**의 한 줄 안내 띠(말귀 요청 (108), 사용자 지시 2026-10-10). 숙제 창 둘(회원 `info` ·
|
|
57
|
+
* 매니저 `.hwd-foot`)이 `.popup-footer` 에 글줄을 싣느라 각자 덧칠했고 **벌써 둘이 달랐다**(한쪽만
|
|
58
|
+
* 실선). 띠 높이 40 · 위로 숨 · 왼쪽 정렬 — 코어가 하나로 쥔다.
|
|
59
|
+
*
|
|
60
|
+
* 바닥 줄(`buttons` · `footer` · `footerStart/End`)과 **따로** 그려진다 — 바닥 줄이 없어도 선다.
|
|
61
|
+
* 글이 길면 줄을 바꾼다(40 은 최소 높이다). `footerNoteDivider` 로 위에 실선을 둔다.
|
|
62
|
+
*/
|
|
63
|
+
footerNote?: ReactNode;
|
|
64
|
+
/** `footerNote` 위에 실선(기본 없음). 창마다 있고 없고가 갈리던 자리라 **값으로** 고른다. */
|
|
65
|
+
footerNoteDivider?: boolean;
|
|
55
66
|
/**
|
|
56
67
|
* 바닥 줄 **왼쪽 덩이** — 보조 단추(아이콘 단추 여럿)가 서는 자리.
|
|
57
68
|
*
|
|
@@ -16,7 +16,7 @@ const ANIM_MS = 260;
|
|
|
16
16
|
/** @public */
|
|
17
17
|
export default function Popup(props) {
|
|
18
18
|
const { t } = useTranslation();
|
|
19
|
-
const { title, subtitle, headerActions, headerSegment, children, buttons, footer, footerStart, footerEnd, footerClassName = "", footerLayout = 'center', onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
|
|
19
|
+
const { title, subtitle, headerActions, headerSegment, children, buttons, footer, footerStart, footerEnd, footerNote, footerNoteDivider = false, footerClassName = "", footerLayout = 'center', onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
|
|
20
20
|
const [closing, setClosing] = useState(false);
|
|
21
21
|
// 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
|
|
22
22
|
// 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
|
|
@@ -214,7 +214,9 @@ export default function Popup(props) {
|
|
|
214
214
|
max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
|
|
215
215
|
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: [title
|
|
216
216
|
? _jsx("h2", { id: titleId, className: "popup-title popup-title--left h2", children: title })
|
|
217
|
-
: _jsx("span", { id: titleId, className: "popup-title popup-title--left h2" }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), headerSegment && (_jsx("div", { className: "popup-header-segment", children: _jsx(SegmentedControl, { size: 40, ariaLabel: headerSegment.ariaLabel, options: headerSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headerSegment.value, onChange: headerSegment.onChange }) })), _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 }) }),
|
|
217
|
+
: _jsx("span", { id: titleId, className: "popup-title popup-title--left h2" }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), headerSegment && (_jsx("div", { className: "popup-header-segment", children: _jsx(SegmentedControl, { size: 40, ariaLabel: headerSegment.ariaLabel, options: headerSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headerSegment.value, onChange: headerSegment.onChange }) })), _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 }) }), footerNote != null && footerNote !== false && (
|
|
218
|
+
/* 바닥 줄 위의 안내 띠 — 바닥 줄과 따로 선다((108)). `role="note"` 로 낭독기에 "참고" 로 읽힌다. */
|
|
219
|
+
_jsx("div", { className: `popup-footer-note caption-1${footerNoteDivider ? ' popup-footer-note--divider' : ''}`, role: "note", children: footerNote })), (footerStart || footerEnd) ? (
|
|
218
220
|
/* 왼쪽·오른쪽 덩이 — 빈 덩이도 그린다(자리를 지켜야 오른쪽이 끝에 선다).
|
|
219
221
|
`footerLayout` 은 보지 않는다: 이 줄 자체가 배치다. */
|
|
220
222
|
_jsxs("div", { className: `popup-footer popup-footer--bar${footerClassName ? ` ${footerClassName}` : ''}`, children: [_jsx("div", { className: "popup-footer__start", children: typeof footerStart === 'function' ? footerStart({ animateThen, closeWithAnimation }) : footerStart }), _jsx("div", { className: "popup-footer__end", children: typeof footerEnd === 'function' ? footerEnd({ animateThen, closeWithAnimation }) : footerEnd })] })) : buttons?.length ? (_jsx("div", { className: `popup-footer popup-footer--buttons${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}`, 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${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
|
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
import './TrainingTooltip.css';
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { TrainingPlan, TrainingItem } from '../../types/training';
|
|
3
4
|
import type { AnchorRectLike } from '../../types/geometry';
|
|
4
5
|
/** 타입 칩 호버 → TrainingTooltip 표시까지의 지연(ms). 말귀자료실 카드와
|
|
5
6
|
* 훈련설정 페이지가 동일한 호버 프리뷰 동작을 각자 구현하고 있어 공유한다.
|
|
6
7
|
* @public */
|
|
7
8
|
export declare const TRAINING_TOOLTIP_HOVER_DELAY_MS = 200;
|
|
9
|
+
/**
|
|
10
|
+
* **자료를 손에 들고 주는 길**(말귀 요청 (111), 사용자 지시 2026-10-10). 갈래 이름표 하나에 그 갈래의
|
|
11
|
+
* `trainingData` 들 — `plan` 처럼 **풀에서 찾지 않는다.** 단어 숙제 세트는 문장 풀에 없어 `plan` 으로는
|
|
12
|
+
* 못 그렸고, 앱이 `tooltip-training__*` 클래스를 **빌려** 같은 모양을 손으로 짰다. 같은 상자를 쓴다.
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export interface TrainingTooltipGroup {
|
|
16
|
+
/** 묶음 머리글 — 갈래 이름 등 */
|
|
17
|
+
label: ReactNode;
|
|
18
|
+
/** 묶음 열쇠(React key·갈래 아이콘용). 없으면 차례로 */
|
|
19
|
+
key?: string;
|
|
20
|
+
/** 블록 단추의 `type`(아이콘·갈래 이름). 없으면 아이콘 없이 */
|
|
21
|
+
type?: {
|
|
22
|
+
key?: string;
|
|
23
|
+
label?: string;
|
|
24
|
+
icon?: string;
|
|
25
|
+
};
|
|
26
|
+
items: TrainingItem[];
|
|
27
|
+
}
|
|
8
28
|
/** @public */
|
|
9
29
|
export interface TooltipTrainingProps {
|
|
10
|
-
|
|
30
|
+
/**
|
|
31
|
+
* 담긴 훈련 — **풀에서 찾는** 길. `items` 의 `trainingType`/`itemId` 로 등록된 풀에서 자료를 찾아
|
|
32
|
+
* 갈래별로 묶는다. 풀에 없는 것(단어 세트 등)은 안 그려진다 → 그때는 `groups`.
|
|
33
|
+
*/
|
|
34
|
+
plan?: TrainingPlan;
|
|
35
|
+
/**
|
|
36
|
+
* 담긴 훈련 — **자료를 손에 들고 주는** 길((111)). 주면 `plan` 은 안 본다. 빈 묶음은 안 그린다.
|
|
37
|
+
*/
|
|
38
|
+
groups?: TrainingTooltipGroup[];
|
|
11
39
|
anchorRect?: DOMRect | AnchorRectLike | null;
|
|
12
40
|
offset?: number;
|
|
13
41
|
onClose?: () => void;
|
|
14
42
|
}
|
|
15
43
|
/** @public */
|
|
16
|
-
export default function TrainingTooltip({ plan, anchorRect, offset, onClose }: TooltipTrainingProps): import("react").JSX.Element | null;
|
|
44
|
+
export default function TrainingTooltip({ plan, groups: givenGroups, anchorRect, offset, onClose }: TooltipTrainingProps): import("react").JSX.Element | null;
|
|
@@ -18,23 +18,35 @@ function findTrainingData(sel) {
|
|
|
18
18
|
return pool.find((td) => td?.id != null && String(td.id) === String(sel.itemId)) ?? null;
|
|
19
19
|
}
|
|
20
20
|
/** @public */
|
|
21
|
-
export default function TrainingTooltip({ plan, anchorRect, offset = 12, onClose }) {
|
|
21
|
+
export default function TrainingTooltip({ plan, groups: givenGroups, anchorRect, offset = 12, onClose }) {
|
|
22
22
|
const { t } = useTranslation();
|
|
23
23
|
// 머리글은 지금 언어로 — 원본 목록의 한국어 라벨을 써 영어 화면에도 '짧은 이야기 듣기' 가 떴다
|
|
24
|
-
const groups = useMemo(() =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
const groups = useMemo(() => {
|
|
25
|
+
/* **두 길은 한 모양으로 모인다** — 그려지는 쪽은 어느 길로 왔는지 모른다. 그래야 모양이 한 곳에 산다. */
|
|
26
|
+
if (givenGroups) {
|
|
27
|
+
return givenGroups
|
|
28
|
+
.map((g, i) => ({ key: g.key ?? g.type?.key ?? String(i), label: g.label, type: g.type, data: (g.items ?? []) }))
|
|
29
|
+
.filter(g => g.data.length > 0);
|
|
30
|
+
}
|
|
31
|
+
/* `plan` 길의 묶음은 **고른 항목이 있으면** 선다 — 풀에서 못 찾은 항목은 단추만 안 선다(머리글은 선다).
|
|
32
|
+
(111) 을 넣으며 한 번 "찾은 자료가 있어야" 로 바꿨다가 옛 못 넷이 울었다 — 풀에 없는 항목만 든
|
|
33
|
+
계획에도 상자는 떠야 한다(타일 호버가 그 상자를 기다린다). 두 길의 "빈 묶음" 기준이 다른 까닭이다. */
|
|
34
|
+
return translateTrainingTypes(t).map(type => {
|
|
35
|
+
const 고른것 = (plan?.items ?? []).filter(i => i.trainingType === type.key);
|
|
36
|
+
return {
|
|
37
|
+
key: type.key,
|
|
38
|
+
label: type.label,
|
|
39
|
+
type,
|
|
40
|
+
고른수: 고른것.length,
|
|
41
|
+
data: 고른것.map(findTrainingData).filter((td) => td != null),
|
|
42
|
+
};
|
|
43
|
+
}).filter(g => g.고른수 > 0);
|
|
44
|
+
}, [givenGroups, plan?.items, t]);
|
|
28
45
|
if (groups.length === 0)
|
|
29
46
|
return null;
|
|
30
|
-
const box = (_jsx("div", { className: "tooltip-training__box", children: _jsx("div", { className: "tooltip-training__groups", children: groups.flatMap(({ type,
|
|
31
|
-
idx > 0 ? _jsx("div", { className: "tooltip-training__divider" }, `div-${
|
|
32
|
-
_jsxs("div", { className: "tooltip-training__section", children: [_jsx("div", { className: "tooltip-training__section-header", children: _jsx("span", { className: "tooltip-training__type-label body-3b", children:
|
|
33
|
-
const td = findTrainingData(item);
|
|
34
|
-
if (!td)
|
|
35
|
-
return null;
|
|
36
|
-
return (_jsx(TrainingBlockButton, { index: i, type: type, trainingData: td, size: "compact", hideCheckBadge: true }, item.itemId ?? i));
|
|
37
|
-
}) })] }, type.key),
|
|
47
|
+
const box = (_jsx("div", { className: "tooltip-training__box", children: _jsx("div", { className: "tooltip-training__groups", children: groups.flatMap(({ key, label, type, data }, idx) => [
|
|
48
|
+
idx > 0 ? _jsx("div", { className: "tooltip-training__divider" }, `div-${key}`) : null,
|
|
49
|
+
_jsxs("div", { className: "tooltip-training__section", children: [_jsx("div", { className: "tooltip-training__section-header", children: _jsx("span", { className: "tooltip-training__type-label body-3b", children: label }) }), _jsx("div", { className: "tooltip-training__items", children: data.map((td, i) => (_jsx(TrainingBlockButton, { index: i, type: type, trainingData: td, size: "compact", hideCheckBadge: true }, String(td.id ?? i)))) })] }, key),
|
|
38
50
|
]) }) }));
|
|
39
51
|
// anchorRect 없으면 박스를 직접 렌더 (position: absolute 없음 — 인라인 미리보기용)
|
|
40
52
|
if (!anchorRect)
|
|
@@ -44,3 +44,21 @@
|
|
|
44
44
|
|
|
45
45
|
.training-blocks--top-spaced { margin-top: 18px; }
|
|
46
46
|
|
|
47
|
+
|
|
48
|
+
/* ── TrainingBlockGrid — 접힐 때만 고르게((110), 2026-10-10) ──
|
|
49
|
+
줄마다 가운데로 모으고, 잰 칸 수만큼만 벌어진다. 치수는 전부 JS 가 **재서** 변수로 준다 —
|
|
50
|
+
여기에 200/190/1140 같은 수를 적지 말 것(앱이 그 수를 베끼던 것을 걷는 자리다). */
|
|
51
|
+
.training-block-grid {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-wrap: wrap;
|
|
54
|
+
align-items: flex-start;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
gap: var(--block-grid-gap, 10px);
|
|
57
|
+
margin: 0 auto;
|
|
58
|
+
}
|
|
59
|
+
.training-block-grid--measured {
|
|
60
|
+
max-width: calc(var(--block-grid-cols) * var(--block-grid-card) + (var(--block-grid-cols) - 1) * var(--block-grid-gap, 10px));
|
|
61
|
+
}
|
|
62
|
+
@media (max-width: 767px) {
|
|
63
|
+
.training-block-grid--measured { max-width: none; }
|
|
64
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import './TrainingBlock.css';
|
|
3
|
+
/** @public */
|
|
4
|
+
export interface TrainingBlockGridProps {
|
|
5
|
+
/** 카드 사이 간격(px). 기본 10 */
|
|
6
|
+
gap?: number;
|
|
7
|
+
/** 세울 카드 수 — 자식 수와 다를 수 있을 때(빈 칸 등) 준다. 없으면 자식 수 */
|
|
8
|
+
count?: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* **정사각 카드를 세우는 그리드 — 접힐 때만 줄을 고르게**((110), 2026-10-10).
|
|
14
|
+
*
|
|
15
|
+
* 한 줄에 다 들면 그대로, 안 들어 접히면 5 → 3+2 · 6 → 3+3 · 7 → 4+3 로 나누고 **그 칸 수만큼만**
|
|
16
|
+
* 벌어진다(마지막 줄이 가운데 모여 좌우가 비지 않는다). 앱이 창 치수 넷을 베껴 세던 것을 걷는다 —
|
|
17
|
+
* 여기는 **제 폭과 첫 카드의 폭을 재서** 센다. 창이 몇이든, 안쪽 여백이 바뀌든 안 베낀다.
|
|
18
|
+
*
|
|
19
|
+
* 재는 것은 둘뿐이다: 이 상자의 폭(부모가 주는 폭 — `max-width` 를 걸기 **전** 폭을 재려고 재는 동안
|
|
20
|
+
* `max-width` 를 푼다), 첫 카드의 폭(코어 `TrainingBlockButton` 정사각 200 · 768~1023 에서 190 —
|
|
21
|
+
* **CSS 가 정한 값을 그대로 읽으므로 여기 안 적는다**). `ResizeObserver` 가 없는 곳(SSR·jsdom)에서는
|
|
22
|
+
* 처음 한 번만 잰다.
|
|
23
|
+
*
|
|
24
|
+
* 좁은 화면(767 이하)에서는 `max-width` 를 안 건다 — 거기서는 카드가 `row` 로 풀폭이다.
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
export default function TrainingBlockGrid({ gap, count, className, children }: TrainingBlockGridProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { cx } from '../../utils/cx';
|
|
5
|
+
import { balancedColumns, columnsFitting } from '../../utils/blockGrid';
|
|
6
|
+
import './TrainingBlock.css';
|
|
7
|
+
function 자식수(children) {
|
|
8
|
+
let n = 0;
|
|
9
|
+
const 세기 = (c) => { if (Array.isArray(c))
|
|
10
|
+
c.forEach(세기);
|
|
11
|
+
else if (c != null && c !== false && c !== true && c !== '')
|
|
12
|
+
n++; };
|
|
13
|
+
세기(children);
|
|
14
|
+
return n;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* **정사각 카드를 세우는 그리드 — 접힐 때만 줄을 고르게**((110), 2026-10-10).
|
|
18
|
+
*
|
|
19
|
+
* 한 줄에 다 들면 그대로, 안 들어 접히면 5 → 3+2 · 6 → 3+3 · 7 → 4+3 로 나누고 **그 칸 수만큼만**
|
|
20
|
+
* 벌어진다(마지막 줄이 가운데 모여 좌우가 비지 않는다). 앱이 창 치수 넷을 베껴 세던 것을 걷는다 —
|
|
21
|
+
* 여기는 **제 폭과 첫 카드의 폭을 재서** 센다. 창이 몇이든, 안쪽 여백이 바뀌든 안 베낀다.
|
|
22
|
+
*
|
|
23
|
+
* 재는 것은 둘뿐이다: 이 상자의 폭(부모가 주는 폭 — `max-width` 를 걸기 **전** 폭을 재려고 재는 동안
|
|
24
|
+
* `max-width` 를 푼다), 첫 카드의 폭(코어 `TrainingBlockButton` 정사각 200 · 768~1023 에서 190 —
|
|
25
|
+
* **CSS 가 정한 값을 그대로 읽으므로 여기 안 적는다**). `ResizeObserver` 가 없는 곳(SSR·jsdom)에서는
|
|
26
|
+
* 처음 한 번만 잰다.
|
|
27
|
+
*
|
|
28
|
+
* 좁은 화면(767 이하)에서는 `max-width` 를 안 건다 — 거기서는 카드가 `row` 로 풀폭이다.
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
export default function TrainingBlockGrid({ gap = 10, count, className, children }) {
|
|
32
|
+
const ref = useRef(null);
|
|
33
|
+
const [잰것, set잰것] = useState({ 폭: 0, 카드: 0 });
|
|
34
|
+
const 장수 = count ?? 자식수(children);
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
const el = ref.current;
|
|
37
|
+
if (!el)
|
|
38
|
+
return;
|
|
39
|
+
const 재기 = () => {
|
|
40
|
+
/* 걸어 둔 max-width 가 제 폭을 가리지 않게 잠깐 푼다 */
|
|
41
|
+
const 전 = el.style.maxWidth;
|
|
42
|
+
el.style.maxWidth = 'none';
|
|
43
|
+
const 폭 = el.clientWidth;
|
|
44
|
+
el.style.maxWidth = 전;
|
|
45
|
+
const 첫 = el.firstElementChild;
|
|
46
|
+
const 카드 = 첫?.offsetWidth ?? 0;
|
|
47
|
+
set잰것(prev => (prev.폭 === 폭 && prev.카드 === 카드 ? prev : { 폭, 카드 }));
|
|
48
|
+
};
|
|
49
|
+
재기();
|
|
50
|
+
if (typeof ResizeObserver === 'undefined')
|
|
51
|
+
return;
|
|
52
|
+
const ro = new ResizeObserver(재기);
|
|
53
|
+
ro.observe(el);
|
|
54
|
+
return () => ro.disconnect();
|
|
55
|
+
}, [장수, gap]);
|
|
56
|
+
const 카드폭 = 잰것.카드 > 0 ? 잰것.카드 : 0;
|
|
57
|
+
const 한줄 = 카드폭 > 0 ? columnsFitting(잰것.폭, 카드폭, gap) : 0;
|
|
58
|
+
/* 아직 못 쟀으면(카드가 없거나 첫 틀) 안 건다 — 0 으로 걸면 다 숨는다 */
|
|
59
|
+
const 칸수 = 한줄 > 0 ? balancedColumns(장수, 한줄) : 0;
|
|
60
|
+
const style = {
|
|
61
|
+
'--block-grid-gap': `${gap}px`,
|
|
62
|
+
...(칸수 > 0 ? { '--block-grid-cols': 칸수, '--block-grid-card': `${카드폭}px` } : {}),
|
|
63
|
+
};
|
|
64
|
+
return (_jsx("div", { ref: ref, className: cx('training-block-grid', 칸수 > 0 && 'training-block-grid--measured', className), style: style, "data-cols": 칸수 > 0 ? 칸수 : undefined, children: children }));
|
|
65
|
+
}
|
|
@@ -14,8 +14,16 @@ export type TrainingStepProps = {
|
|
|
14
14
|
* **왜 아직 못 누르는지 알리는 한 줄** — 잠긴 단계 **바로 다음 차례**에만 선다
|
|
15
15
|
* (말귀 요청 (99), 2026-10-08).
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* **주는 곳에서만 선다 — 안 주면 아무것도 안 선다**(말귀 요청 (107), 사용자 지시 2026-10-10:
|
|
18
|
+
* 실행 창의 *"다 들으면 고를 수 있어요."* 를 보고 *"누가 추가했어, 제거해"*). 0.302.0 ~ 0.309.0
|
|
19
|
+
* 에는 안 줘도 그 말이 기본으로 섰는데, **앱에서는 끌 길이 없었다** — 그 단계는 코어 러너가
|
|
20
|
+
* 세워서 앱이 이 prop 을 만질 자리가 없다(앱 저장소 0건). 켜고 싶으면 **앱 사전에** 그 말을 두고
|
|
21
|
+
* `lockedHint={t(…)}` 로 준다 — 코어 열쇠 `training.answerLockedHint` 는 0.311.0 에서 걷었다(부르는
|
|
22
|
+
* 곳 0·켤 자리 0 인 열쇠는 아직 안 쓰인 길을 미리 내 둔 것이다). `null` 은 안 준 것과 같다.
|
|
23
|
+
*
|
|
24
|
+
* (99) 의 낭독기 쪽 까닭(단추가 진짜 `disabled` 라 초점이 안 가 까닭을 들을 길이 없다)은
|
|
25
|
+
* 그대로 참이다 — 주면 여전히 `role="status"` 로 선다. 보이는 글 없이 그 길만 지키려면
|
|
26
|
+
* 부르는 쪽이 시각적으로 숨긴 노드를 주면 된다. 코어가 몰래 세우지는 않는다.
|
|
19
27
|
*/
|
|
20
28
|
lockedHint?: ReactNode | null;
|
|
21
29
|
};
|
|
@@ -87,6 +87,6 @@ export default function TrainingStep({ index, stepIndex, isLast = false, title =
|
|
|
87
87
|
}
|
|
88
88
|
return child;
|
|
89
89
|
});
|
|
90
|
-
return (_jsxs("div", { className: `training-step training-step--${currentState} ${isLast ? "training-step--last" : ""} ${className}`, "aria-current": currentState === 'active', children: [_jsx(TrainingStepBullet, { state: currentState }), _jsxs("div", { className: "training-step__content", children: [_jsx(TrainingStepTitle, { state: currentState, title: title, ...titleProps, useFaded: useTitleFaded }), currentState === 'disabled' && lockedHint
|
|
91
|
-
&& typeof index === 'number' && typeof stepIndex === 'number' && index === stepIndex + 1 && (_jsx("p", { className: "training-step__locked-hint caption-1", role: "status", children: lockedHint
|
|
90
|
+
return (_jsxs("div", { className: `training-step training-step--${currentState} ${isLast ? "training-step--last" : ""} ${className}`, "aria-current": currentState === 'active', children: [_jsx(TrainingStepBullet, { state: currentState }), _jsxs("div", { className: "training-step__content", children: [_jsx(TrainingStepTitle, { state: currentState, title: title, ...titleProps, useFaded: useTitleFaded }), currentState === 'disabled' && lockedHint
|
|
91
|
+
&& typeof index === 'number' && typeof stepIndex === 'number' && index === stepIndex + 1 && (_jsx("p", { className: "training-step__locked-hint caption-1", role: "status", children: lockedHint })), mappedChildren] }), tooltipOpen ? (_jsx(Tooltip, { anchorRect: tooltipOpts.anchorRect, backdrop: tooltipOpts.backdrop, onClose: hideTooltip, ariaLabel: t('tooltip.textbox', '본문'), children: _jsx(TextBoxTooltip, { text: tooltipOpts.text, onClose: hideTooltip }) })) : null] }));
|
|
92
92
|
}
|