@ihabdevteam/core 0.310.0 → 0.312.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 +90 -0
- package/dist/components.cjs.js +176 -176
- 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 +101 -6
- 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/LoadTablePopup.js +3 -1
- 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/SideNav.js +24 -5
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +16 -1
- 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 +3 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +14 -9
- package/dist/packages/core/src/components.d.ts +3 -1
- package/dist/packages/core/src/components.js +1 -0
- package/dist/packages/core/src/context/WordDataProvider.d.ts +1 -1
- package/dist/packages/core/src/context/WordDataProvider.js +13 -1
- 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.312.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;
|
|
@@ -9214,8 +9288,9 @@ export declare type TrainingStepProps = {
|
|
|
9214
9288
|
* **주는 곳에서만 선다 — 안 주면 아무것도 안 선다**(말귀 요청 (107), 사용자 지시 2026-10-10:
|
|
9215
9289
|
* 실행 창의 *"다 들으면 고를 수 있어요."* 를 보고 *"누가 추가했어, 제거해"*). 0.302.0 ~ 0.309.0
|
|
9216
9290
|
* 에는 안 줘도 그 말이 기본으로 섰는데, **앱에서는 끌 길이 없었다** — 그 단계는 코어 러너가
|
|
9217
|
-
* 세워서 앱이 이 prop 을 만질 자리가 없다(앱 저장소 0건). 켜고 싶으면
|
|
9218
|
-
*
|
|
9291
|
+
* 세워서 앱이 이 prop 을 만질 자리가 없다(앱 저장소 0건). 켜고 싶으면 **앱 사전에** 그 말을 두고
|
|
9292
|
+
* `lockedHint={t(…)}` 로 준다 — 코어 열쇠 `training.answerLockedHint` 는 0.311.0 에서 걷었다(부르는
|
|
9293
|
+
* 곳 0·켤 자리 0 인 열쇠는 아직 안 쓰인 길을 미리 내 둔 것이다). `null` 은 안 준 것과 같다.
|
|
9219
9294
|
*
|
|
9220
9295
|
* (99) 의 낭독기 쪽 까닭(단추가 진짜 `disabled` 라 초점이 안 가 까닭을 들을 길이 없다)은
|
|
9221
9296
|
* 그대로 참이다 — 주면 여전히 `role="status"` 로 선다. 보이는 글 없이 그 길만 지키려면
|
|
@@ -9268,7 +9343,27 @@ export declare interface TrainingTimeLogEntry {
|
|
|
9268
9343
|
declare type TrainingTimeLogger = (entry: TrainingTimeLogEntry) => Promise<void> | void;
|
|
9269
9344
|
|
|
9270
9345
|
/** @public */
|
|
9271
|
-
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
|
+
}
|
|
9272
9367
|
|
|
9273
9368
|
/** @public */
|
|
9274
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);
|
|
@@ -6,6 +6,7 @@ import Popup from './Popup';
|
|
|
6
6
|
import Button from '../Button';
|
|
7
7
|
import Dropdown from '../Dropdown';
|
|
8
8
|
import { getSavedTables, deleteTable as localDeleteTable } from '../../utils/savedWordTables';
|
|
9
|
+
import { 한글순 } from '../../utils/textSort';
|
|
9
10
|
import { formatYmd } from '../../utils/date';
|
|
10
11
|
import './LoadTablePopup.css';
|
|
11
12
|
import { pressable } from '../../utils/pressable';
|
|
@@ -58,8 +59,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
58
59
|
return copy.sort((a, b) => String(b.savedAt ?? '').localeCompare(String(a.savedAt ?? '')));
|
|
59
60
|
if (sortKey === 'oldest')
|
|
60
61
|
return copy.sort((a, b) => String(a.savedAt ?? '').localeCompare(String(b.savedAt ?? '')));
|
|
62
|
+
/* 이름은 **`한글순`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
|
|
61
63
|
if (sortKey === 'name')
|
|
62
|
-
return copy.sort((a, b) => a.name
|
|
64
|
+
return copy.sort((a, b) => 한글순(a.name, b.name));
|
|
63
65
|
if (sortKey === 'count')
|
|
64
66
|
return copy.sort((a, b) => b.items.length - a.items.length);
|
|
65
67
|
return copy;
|
|
@@ -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] }) }));
|
|
@@ -71,26 +71,45 @@ export default function SideNav(props) {
|
|
|
71
71
|
const el = 통ref.current;
|
|
72
72
|
if (!el)
|
|
73
73
|
return;
|
|
74
|
-
|
|
74
|
+
/* **굴림 한 번에 한 번만 센다**(말귀 요청 (115), 2026-10-10). 전에는 `scroll` 마다 바로 `setState` 라
|
|
75
|
+
굴리는 동안 틀마다 여러 번 다시 그렸다. 틀에 한 번으로 모으고(rAF), **값이 그대로면 상태를 안
|
|
76
|
+
건드린다** — 같은 수를 새 객체로 넘기면 React 는 바뀐 줄 안다. */
|
|
77
|
+
let 예약 = null;
|
|
78
|
+
const 셈 = () => {
|
|
75
79
|
const { scrollHeight, clientHeight, scrollTop } = el;
|
|
76
80
|
/* 넘치지 않으면 그리지 않는다 — 없는 스크롤을 알릴 까닭이 없다 */
|
|
77
81
|
if (scrollHeight <= clientHeight + 1) {
|
|
78
|
-
set막대(null);
|
|
82
|
+
set막대(prev => (prev === null ? prev : null));
|
|
79
83
|
return;
|
|
80
84
|
}
|
|
81
85
|
const 길이 = Math.max(24, (clientHeight / scrollHeight) * clientHeight);
|
|
82
86
|
const 남은길 = clientHeight - 길이;
|
|
83
87
|
const 남은굴림 = scrollHeight - clientHeight;
|
|
84
|
-
|
|
88
|
+
const 다음 = { top: Math.round((scrollTop / 남은굴림) * 남은길), height: Math.round(길이) };
|
|
89
|
+
set막대(prev => (prev && prev.top === 다음.top && prev.height === 다음.height ? prev : 다음));
|
|
85
90
|
};
|
|
86
|
-
재기()
|
|
91
|
+
const 재기 = () => {
|
|
92
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
93
|
+
셈();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (예약 !== null)
|
|
97
|
+
return;
|
|
98
|
+
예약 = requestAnimationFrame(() => { 예약 = null; 셈(); });
|
|
99
|
+
};
|
|
100
|
+
셈();
|
|
87
101
|
el.addEventListener('scroll', 재기, { passive: true });
|
|
88
102
|
/* 항목이 늘거나 창이 바뀌면 길이도 달라진다 */
|
|
89
103
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(재기) : null;
|
|
90
104
|
ro?.observe(el);
|
|
91
105
|
for (const c of Array.from(el.children))
|
|
92
106
|
ro?.observe(c);
|
|
93
|
-
return () => {
|
|
107
|
+
return () => {
|
|
108
|
+
if (예약 !== null)
|
|
109
|
+
cancelAnimationFrame(예약);
|
|
110
|
+
el.removeEventListener('scroll', 재기);
|
|
111
|
+
ro?.disconnect();
|
|
112
|
+
};
|
|
94
113
|
}, [items]);
|
|
95
114
|
/* **막대를 잡아 끌 수 있다.** 손에 잡히게 그려 놓고 안 잡히면 그 꼴이 거짓말이 된다.
|
|
96
115
|
통의 한 픽셀은 막대의 `scrollHeight / clientHeight` 배다 — 그 비로 옮긴다. */
|
|
@@ -140,7 +140,20 @@ export default function Tooltip(props) {
|
|
|
140
140
|
return;
|
|
141
141
|
if (isMobile && isMobilePopup)
|
|
142
142
|
return;
|
|
143
|
-
|
|
143
|
+
/* **자리 다시 재기는 틀에 한 번으로 모은다**(말귀 요청 (115), 2026-10-10). 이 듣는 이는 문서 전체의
|
|
144
|
+
굴림을 **capture** 로 받아, 굴리는 동안 틀마다 여러 번 `computePosition` 이 돌았다(거기서 레이아웃을
|
|
145
|
+
읽는다). **닫는 길은 안 미룬다** — 아래 `onScroll` 의 닫기는 그대로 즉시다. 미루면 앵커에서 떨어진
|
|
146
|
+
툴팁이 한 틀 더 허공에 남는다. 미루는 것은 *자리* 뿐이다. */
|
|
147
|
+
let 예약 = null;
|
|
148
|
+
const onWindowChange = () => {
|
|
149
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
150
|
+
computePosition(true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (예약 !== null)
|
|
154
|
+
return;
|
|
155
|
+
예약 = requestAnimationFrame(() => { 예약 = null; computePosition(true); });
|
|
156
|
+
};
|
|
144
157
|
/* 고정 사각형(anchorRect)으로 띄운 툴팁은 스크롤해도 앵커를 따라갈 수 없다 — 같은 자리를 다시 계산해 **허공에** 남았다
|
|
145
158
|
(목록을 굴리면 카드 메뉴가 카드에서 떨어져 뜬 채). 닫을 곳(onClose)이 있으면 닫는다. 툴팁 안 스크롤은 제외. */
|
|
146
159
|
const onScroll = (e) => {
|
|
@@ -162,6 +175,8 @@ export default function Tooltip(props) {
|
|
|
162
175
|
window.addEventListener('resize', onWindowChange);
|
|
163
176
|
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
164
177
|
return () => {
|
|
178
|
+
if (예약 !== null)
|
|
179
|
+
cancelAnimationFrame(예약);
|
|
165
180
|
window.removeEventListener('resize', onWindowChange);
|
|
166
181
|
document.removeEventListener('scroll', onScroll, { capture: true });
|
|
167
182
|
};
|
|
@@ -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
|
+
}
|
|
@@ -17,8 +17,9 @@ export type TrainingStepProps = {
|
|
|
17
17
|
* **주는 곳에서만 선다 — 안 주면 아무것도 안 선다**(말귀 요청 (107), 사용자 지시 2026-10-10:
|
|
18
18
|
* 실행 창의 *"다 들으면 고를 수 있어요."* 를 보고 *"누가 추가했어, 제거해"*). 0.302.0 ~ 0.309.0
|
|
19
19
|
* 에는 안 줘도 그 말이 기본으로 섰는데, **앱에서는 끌 길이 없었다** — 그 단계는 코어 러너가
|
|
20
|
-
* 세워서 앱이 이 prop 을 만질 자리가 없다(앱 저장소 0건). 켜고 싶으면
|
|
21
|
-
*
|
|
20
|
+
* 세워서 앱이 이 prop 을 만질 자리가 없다(앱 저장소 0건). 켜고 싶으면 **앱 사전에** 그 말을 두고
|
|
21
|
+
* `lockedHint={t(…)}` 로 준다 — 코어 열쇠 `training.answerLockedHint` 는 0.311.0 에서 걷었다(부르는
|
|
22
|
+
* 곳 0·켤 자리 0 인 열쇠는 아직 안 쓰인 길을 미리 내 둔 것이다). `null` 은 안 준 것과 같다.
|
|
22
23
|
*
|
|
23
24
|
* (99) 의 낭독기 쪽 까닭(단추가 진짜 `disabled` 라 초점이 안 가 까닭을 들을 길이 없다)은
|
|
24
25
|
* 그대로 참이다 — 주면 여전히 `role="status"` 로 선다. 보이는 글 없이 그 길만 지키려면
|