@ihabdevteam/core 0.184.0 → 0.186.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 +202 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +5 -5
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +180 -180
- package/dist/ihabdevteam-core.d.ts +26 -6
- package/dist/packages/core/src/components/BottomNav.js +34 -22
- package/dist/packages/core/src/components/Calendar.css +32 -5
- package/dist/packages/core/src/components/Calendar.js +1 -1
- package/dist/packages/core/src/components/ChartSection.css +6 -0
- package/dist/packages/core/src/components/FilterPanel.js +5 -2
- package/dist/packages/core/src/components/Header.d.ts +3 -1
- package/dist/packages/core/src/components/Header.js +6 -4
- package/dist/packages/core/src/components/PageTitle.css +7 -3
- package/dist/packages/core/src/components/Popup/Popup.css +15 -6
- package/dist/packages/core/src/components/Section.css +6 -0
- package/dist/packages/core/src/components/SegmentedControl.d.ts +5 -0
- package/dist/packages/core/src/components/SideNav.d.ts +2 -0
- package/dist/packages/core/src/components/SideNav.js +5 -5
- package/dist/packages/core/src/components/Table.d.ts +6 -3
- package/dist/packages/core/src/components/Table.js +44 -8
- package/dist/packages/core/src/components/Toolbar.css +21 -0
- package/dist/packages/core/src/components/Toolbar.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.css +14 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +30 -4
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +12 -0
- package/dist/packages/core/src/components/Training/WordTablePopup.css +18 -0
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.css +25 -1
- package/dist/packages/core/src/components/WordTable.css +19 -0
- package/dist/packages/core/src/components/WordTable.js +21 -2
- package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
- package/dist/packages/core/src/components/useFitScale.js +17 -1
- package/dist/packages/core/src/i18n/locales/en.json +4 -2
- package/dist/packages/core/src/i18n/locales/ko.json +4 -2
- 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
|
@@ -76,12 +76,39 @@ function TableField({ children, onClick, className, sortDir, ariaSort, onResizeS
|
|
|
76
76
|
/** @public */
|
|
77
77
|
export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
|
|
78
78
|
const { t } = useTranslation();
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* **좁은 화면에서는 폭 조절을 통째로 끈다** — 손잡이·초기화 단추·저장값.
|
|
81
|
+
*
|
|
82
|
+
* 예전엔 `mobileGridTemplateColumns` 를 준 표에서만 껐다. 그런데 손잡이는 열마다
|
|
83
|
+
* 오른쪽 끝 10px 을 `touch-action: none` 으로 먹는 띠다 — 폰에서 표를 옆으로 밀려고
|
|
84
|
+
* 짚은 손가락이 그 띠에 닿으면 스크롤이 아니라 **폭 조절**이 되고, 손을 떼는 순간
|
|
85
|
+
* 그 값이 저장소에 박힌다. 폰에서는 칸 하나가 밑값(48px)까지 눌리기 쉬워, 그 뒤로는
|
|
86
|
+
* 열어도 열어도 눌린 표가 나온다(말귀 '오늘의 듣기 현황': 앱이 준
|
|
87
|
+
* `minmax(130px,0.9fr) minmax(140px,1.1fr) 150px 110px 160px` 인데 `48px 48px …` 로 서고
|
|
88
|
+
* 줄 높이가 161px 이 됐다 — 2026-09-22 실측).
|
|
89
|
+
*
|
|
90
|
+
* 게다가 저장값은 화면 폭마다 따로 두지 않는다. 넓은 화면에서 끌어 둔 px 폭이 폰에도
|
|
91
|
+
* 그대로 씌워지므로, 좁은 화면에서는 **만든 사람이 적어 둔 틀**로 돌아가는 편이 맞다 —
|
|
92
|
+
* 그 틀의 `minmax(…px, …fr)` 밑값이 살아나 칸이 제 폭을 갖고, 넘치는 만큼은 표가
|
|
93
|
+
* 제 가로 스크롤로 보여 준다. 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다.
|
|
94
|
+
*/
|
|
80
95
|
const 좁은화면 = useMediaQuery('(max-width: 767px)');
|
|
81
96
|
const 좁은틀 = mobileGridTemplateColumns && 좁은화면 ? mobileGridTemplateColumns : null;
|
|
82
|
-
const 폭조절 = resizable &&
|
|
97
|
+
const 폭조절 = resizable && !좁은화면;
|
|
83
98
|
// ── 컬럼 폭 드래그 리사이즈 (resizable) ──
|
|
99
|
+
/** 만든 사람이 밑값을 안 적은 칸에 쓰는 바닥 — 이보다 좁으면 손잡이를 다시 잡을 수도 없다 */
|
|
84
100
|
const MIN_COL_W = 48;
|
|
101
|
+
/**
|
|
102
|
+
* **그 칸의 가장 좁은 폭** — `minmax(130px, 0.9fr)` 의 130, 못 박은 `150px` 의 150.
|
|
103
|
+
*
|
|
104
|
+
* 밑값을 한 값(48px)으로 두면 만든 사람이 적어 둔 바닥을 넘어 깎인다. 고정 칸 합이
|
|
105
|
+
* 자리보다 넓을 때 유동 칸을 눌러 버리는 쪽인데, **밑값이 있으면 지키고 넘치는 만큼
|
|
106
|
+
* 굴리는 것**이 맞다(가로 스크롤). `test/tableColumnMinWidth.test.ts` 가 요구하는
|
|
107
|
+
* `minmax(120px, 1fr)` 도 그래야 뜻이 있다.
|
|
108
|
+
*
|
|
109
|
+
* 자동맞춤은 이 값보다 위에서 정의되므로 ref 로 건넨다(isFixedPxRef 와 같은 이유).
|
|
110
|
+
*/
|
|
111
|
+
const 밑값Ref = useRef(() => MIN_COL_W);
|
|
85
112
|
const storeKey = resizeStorageKey ? `table-cols:${resizeStorageKey}` : null;
|
|
86
113
|
const fieldsRef = useRef(null);
|
|
87
114
|
const isFixedPxRef = useRef(() => false);
|
|
@@ -127,12 +154,12 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
127
154
|
// fit — 이동량을 두 컬럼이 나눠 갖는다. 둘 다 최소폭 밑으로 내려가지 않도록
|
|
128
155
|
// 이동량 자체를 먼저 자른다(한쪽만 자르면 합이 달라져 표 폭이 흔들린다).
|
|
129
156
|
const raw = e.clientX - d.startX;
|
|
130
|
-
const delta = Math.max(
|
|
157
|
+
const delta = Math.max(밑값Ref.current(d.index) - d.startW, Math.min(d.startWNext - 밑값Ref.current(d.next), raw));
|
|
131
158
|
copy[d.index] = d.startW + delta;
|
|
132
159
|
copy[d.next] = d.startWNext - delta;
|
|
133
160
|
}
|
|
134
161
|
else {
|
|
135
|
-
copy[d.index] = Math.max(
|
|
162
|
+
copy[d.index] = Math.max(밑값Ref.current(d.index), d.startW + (e.clientX - d.startX));
|
|
136
163
|
}
|
|
137
164
|
colWidthsRef.current = copy;
|
|
138
165
|
setColWidths(copy);
|
|
@@ -213,7 +240,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
213
240
|
/* 고정 px 열은 정해 둔 값 그대로 — 최소 폭(MIN_COL_W)으로 올린 값을 고정 합에 넣으면 격자에는 원래 값(40px)으로 나가
|
|
214
241
|
그만큼(8px) 표 오른쪽이 비었다(말귀 어드민 버그 탭·쿠폰) */
|
|
215
242
|
const 고정값 = isFixedPxRef.current(i) ? parseFloat(defaultColsRef.current[i]) : NaN;
|
|
216
|
-
return Number.isFinite(고정값) ? 고정값 : Math.max(
|
|
243
|
+
return Number.isFinite(고정값) ? 고정값 : Math.max(밑값Ref.current(i), Math.ceil(w));
|
|
217
244
|
});
|
|
218
245
|
/* 격자가 실제로 채우는 폭 — `clientWidth` 는 안쪽 여백을 포함하므로 뺀다
|
|
219
246
|
(되돌리기 단추 자리로 40px 을 비워 두었다). */
|
|
@@ -237,7 +264,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
237
264
|
const 남은 = 그릇 - 고정;
|
|
238
265
|
if (그릇 > 0 && 유동 > 0 && 남은 > 0) {
|
|
239
266
|
const 비 = 남은 / 유동;
|
|
240
|
-
최종 = 잰것.map((w, i) => (isFixedPxRef.current(i) ? w : Math.max(
|
|
267
|
+
최종 = 잰것.map((w, i) => (isFixedPxRef.current(i) ? w : Math.max(밑값Ref.current(i), Math.round(w * 비))));
|
|
241
268
|
/* 반올림으로 남은 몇 px 은 가장 넓은 유동 열에 얹는다 — 안 그러면 늘 몇 px 이
|
|
242
269
|
비거나 넘친다. 최소 폭에 걸린 열이 있으면 넘칠 수 있는데, 그건 담을 수
|
|
243
270
|
없을 만큼 좁다는 뜻이라 가로 스크롤로 두는 편이 맞다. */
|
|
@@ -247,7 +274,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
247
274
|
최종.forEach((w, i) => { if (!isFixedPxRef.current(i) && (넓은 < 0 || w > 최종[넓은]))
|
|
248
275
|
넓은 = i; });
|
|
249
276
|
if (넓은 >= 0)
|
|
250
|
-
최종[넓은] = Math.max(
|
|
277
|
+
최종[넓은] = Math.max(밑값Ref.current(넓은), 최종[넓은] + 차);
|
|
251
278
|
}
|
|
252
279
|
}
|
|
253
280
|
setColWidths(최종);
|
|
@@ -325,11 +352,20 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
325
352
|
return fields[i].onClick ? 'none' : undefined;
|
|
326
353
|
};
|
|
327
354
|
const isFixedPx = (i) => /^\d+(\.\d+)?px$/.test(defaultCols[i] ?? '');
|
|
328
|
-
|
|
355
|
+
/** 그 칸에 적힌 밑값 — `minmax(130px, …)` 의 130, 못 박은 `150px` 의 150, 없으면 MIN_COL_W */
|
|
356
|
+
const 선언밑값 = (i) => {
|
|
357
|
+
const t = defaultCols[i] ?? '';
|
|
358
|
+
const m = /^minmax\(\s*(\d+(?:\.\d+)?)px\s*,/.exec(t) ?? /^(\d+(?:\.\d+)?)px$/.exec(t);
|
|
359
|
+
return m ? parseFloat(m[1]) : MIN_COL_W;
|
|
360
|
+
};
|
|
361
|
+
/* `자동맞춤` 은 이 셋보다 위에서 정의되므로(콜백이라 부를 때 읽힌다) ref 로 건넨다 */
|
|
329
362
|
isFixedPxRef.current = isFixedPx;
|
|
363
|
+
밑값Ref.current = 선언밑값;
|
|
330
364
|
defaultColsRef.current = defaultCols;
|
|
331
365
|
const widthUnit = (i) => (resizeMode === 'fit' && !isFixedPx(i)) ? 'fr' : 'px';
|
|
366
|
+
/* 좁은 화면에서는 저장값을 아예 안 쓴다 — 위 `폭조절` 의 글을 보라(넓은 화면에서 끈 폭이 폰에 씌워진다) */
|
|
332
367
|
const canApplyWidths = colWidths && colWidths.length === fields.length
|
|
368
|
+
&& !좁은화면
|
|
333
369
|
&& (resizeMode === 'fit' || items.length > 0);
|
|
334
370
|
const effectiveGrid = 좁은틀 ?? (canApplyWidths
|
|
335
371
|
? fields.map((f, i) => (f.resizable === false && defaultCols[i]) ? defaultCols[i] : `${colWidths[i]}${widthUnit(i)}`).join(' ')
|
|
@@ -38,6 +38,27 @@
|
|
|
38
38
|
gap: var(--spacing-s);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/* **안쪽 묶음이 줄 전체로 늘어나면 `justify-content` 가 먹을 여지가 없다.**
|
|
42
|
+
우측 묶음의 한 자식(예: 기간 조작 `.manager-section-controls`)이 제 안에서 줄을 바꾸면,
|
|
43
|
+
그 자식의 폭이 줄 전체가 되어(375px 에서 335px) 안의 항목들이 **왼쪽부터** 깔렸다 —
|
|
44
|
+
우측 묶음은 오른쪽에 붙었는데 그 안은 왼쪽에 붙는 꼴이다(말귀 실측 2026-09-22).
|
|
45
|
+
늘어나는 것 자체는 막지 않는다(막으면 좁은 화면에서 그 묶음이 제 줄을 못 쓴다).
|
|
46
|
+
대신 **늘어나도 안쪽이 오른쪽에 서게** 한다 — 원래 오른쪽 것이던 자리다. */
|
|
47
|
+
.toolbar__row-end > * {
|
|
48
|
+
justify-content: flex-end;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* 페이지 크기 + 쪽수 — 한 덩어리로 움직인다. 안에서는 줄을 바꾸지 않는다(`nowrap`):
|
|
52
|
+
둘이 갈라지면 "50개씩 보기" 만 남고 쪽수가 다음 줄로 떨어져, 같은 것을 다루는 짝이
|
|
53
|
+
따로 노는 것처럼 보였다(말귀 실측 2026-09-22). 좁으면 이 덩어리가 통째로 내려간다. */
|
|
54
|
+
.toolbar__pager {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: var(--spacing-s);
|
|
58
|
+
flex-wrap: nowrap;
|
|
59
|
+
flex-shrink: 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
.toolbar__count {
|
|
42
63
|
flex-shrink: 0;
|
|
43
64
|
margin: 0;
|
|
@@ -18,5 +18,5 @@ export default function Toolbar({ count, search, segment, filters, pageSize, pag
|
|
|
18
18
|
/* placeholder는 접근 가능한 이름이 아니다 — 입력이 시작되면 사라지고
|
|
19
19
|
화면낭독기 지원도 제각각이라, 같은 문구를 aria-label로도 준다.
|
|
20
20
|
(툴바 검색은 시각적 라벨이 따로 없는 자리라 이게 유일한 이름이 된다.) */
|
|
21
|
-
ariaLabel: search.placeholder ?? t('common.search', '검색'), clearable: true })), filters?.map((f, i) => (_jsx(Dropdown, { size: 40, options: f.options, value: f.value, onChange: f.onChange, ariaLabel: f.ariaLabel }, i))), pageSize && _jsx(Dropdown, { size: 40, options: pageSize.options, value: pageSize.value, onChange: pageSize.onChange, ariaLabel: pageSize.ariaLabel ?? t('common.pageSizeSelect', '페이지당 표시 개수') }), pagination && (_jsx(Pagination, { page: pagination.page, totalPages: pagination.total, onChange: pagination.onChange, prevLabel: pagination.prevLabel, nextLabel: pagination.nextLabel })), actions] }) }))] }));
|
|
21
|
+
ariaLabel: search.placeholder ?? t('common.search', '검색'), clearable: true })), filters?.map((f, i) => (_jsx(Dropdown, { size: 40, options: f.options, value: f.value, onChange: f.onChange, ariaLabel: f.ariaLabel }, i))), (pageSize || pagination) && (_jsxs("div", { className: "toolbar__pager", children: [pageSize && _jsx(Dropdown, { size: 40, options: pageSize.options, value: pageSize.value, onChange: pageSize.onChange, ariaLabel: pageSize.ariaLabel ?? t('common.pageSizeSelect', '페이지당 표시 개수') }), pagination && (_jsx(Pagination, { page: pagination.page, totalPages: pagination.total, onChange: pagination.onChange, prevLabel: pagination.prevLabel, nextLabel: pagination.nextLabel }))] })), actions] }) }))] }));
|
|
22
22
|
}
|
|
@@ -905,7 +905,9 @@ function TrainingPagePopupInner(props) {
|
|
|
905
905
|
아무 데도 닿지 않았다** — 낭독기는 이것을 그냥 대화상자로 읽고 가상 커서가
|
|
906
906
|
뒤 화면으로 새어 나간다(Tab 가둠은 `useFocusTrap` 이 따로 하고 있었다).
|
|
907
907
|
둘을 한 요소에 모은다. */
|
|
908
|
-
_jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount
|
|
908
|
+
_jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount,
|
|
909
|
+
/* 결과 창이 뜨면 바닥의 오디오 미터를 끈다 — 결과를 보는 동안 떠 있을 까닭이 없다 */
|
|
910
|
+
resultOpen: Boolean(resultOverlay && !resultOverlay.isEmpty) })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
|
|
909
911
|
score: resultOverlay.score,
|
|
910
912
|
results: (resultOverlay.results || resultOverlay.answers || []),
|
|
911
913
|
contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
|
|
@@ -43,3 +43,17 @@
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
.pp-bottom__tooltip-trigger { display: inline-block; }
|
|
46
|
+
|
|
47
|
+
/* 폰에서 토글 단추 위에 떠서 보이는 미터 — 바닥 줄에 나란히 두면 미터 두 줄(222px)이
|
|
48
|
+
'다음 문제'(150px)를 화면 밖으로 밀어낸다(말귀 375px 실측: 보이는 폭 5px, 바닥이
|
|
49
|
+
overflow:visible 이라 굴려 꺼낼 수도 없었다). 떠 있는 판에서는 두 줄을 세로로 쌓는다. */
|
|
50
|
+
.pp-bottom__meters--floating {
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: var(--spacing-xs);
|
|
54
|
+
}
|
|
55
|
+
/* 토글 단추 자리 — 떠 있는 판의 기준점이라 흐름에서 제 크기를 지킨다 */
|
|
56
|
+
.pp-bottom__meter-trigger {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
}
|
|
@@ -6,10 +6,12 @@ export type TrainingPagePopupBottomProps = {
|
|
|
6
6
|
onNext?: () => void;
|
|
7
7
|
onResult?: () => void;
|
|
8
8
|
stepIndex?: number;
|
|
9
|
+
/** 결과 창이 떠 있는가 — 뜨는 순간 오디오 미터를 끈다(사용자 지시 2026-09-22). */
|
|
10
|
+
resultOpen?: boolean;
|
|
9
11
|
stepCount?: number;
|
|
10
12
|
pageIndex?: number;
|
|
11
13
|
/** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
|
|
12
14
|
pageCount?: number;
|
|
13
15
|
};
|
|
14
16
|
/** @public */
|
|
15
|
-
export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, }: TrainingPagePopupBottomProps): import("react").JSX.Element;
|
|
17
|
+
export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): import("react").JSX.Element;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import AudioMeter from '../AudioMeter';
|
|
7
7
|
import NoiseSettingsTooltip from './NoiseSettingsTooltip';
|
|
8
|
+
import Tooltip from '../Tooltip/Tooltip';
|
|
9
|
+
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
8
10
|
import { useNoisePlayer } from './NoisePlayerContext';
|
|
9
11
|
import { useFloatingFocus } from './useFloatingFocus';
|
|
10
12
|
import "./TrainingPagePopupBottom.css";
|
|
11
13
|
/** @public */
|
|
12
|
-
export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, onSettings, onNext, onResult, stepIndex = 0, stepCount = 0, pageIndex = 0, pageCount = 1, }) {
|
|
14
|
+
export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, onSettings, onNext, onResult, stepIndex = 0, stepCount = 0, pageIndex = 0, pageCount = 1, resultOpen = false, }) {
|
|
13
15
|
const { t } = useTranslation();
|
|
14
16
|
const [open, setOpen] = useState(false);
|
|
15
17
|
const [anchorRect, setAnchorRect] = useState(null);
|
|
@@ -58,7 +60,31 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
|
|
|
58
60
|
onSettings?.();
|
|
59
61
|
setOpen(true);
|
|
60
62
|
}, [open, onSettings]);
|
|
61
|
-
|
|
63
|
+
/* **폰에서는 미터가 바닥 줄에 서지 않는다.** 미터 두 줄이 222px 를 먹어 '다음 문제'(150px)가
|
|
64
|
+
화면 밖으로 밀렸다 — 375px 에서 보이는 폭이 5px 뿐이었고, 바닥이 `overflow: visible` 이라
|
|
65
|
+
굴려서 꺼낼 수도 없었다(말귀 실측 2026-09-22: 훈련을 이어갈 수 없다). 폰에서는 토글 단추
|
|
66
|
+
**위에 떠서** 보인다(사용자 지시). 데스크톱은 그대로 바닥 줄에 나란히 선다 — 거기서는
|
|
67
|
+
자리가 넉넉하다(1659px 창에서 왼쪽 묶음 434px). */
|
|
68
|
+
const 폰 = useMediaQuery('(max-width: 767px)');
|
|
69
|
+
const 미터토글ref = useRef(null);
|
|
70
|
+
const [미터자리, set미터자리] = useState(null);
|
|
71
|
+
const handleToggleMeters = useCallback(() => {
|
|
72
|
+
setShowMeters(prev => {
|
|
73
|
+
const 다음 = !prev;
|
|
74
|
+
set미터자리(다음 ? (미터토글ref.current?.getBoundingClientRect() ?? null) : null);
|
|
75
|
+
return 다음;
|
|
76
|
+
});
|
|
77
|
+
}, []);
|
|
78
|
+
const 미터닫기 = useCallback(() => { setShowMeters(false); set미터자리(null); }, []);
|
|
79
|
+
/* **결과 창이 뜨면 미터를 끈다**(사용자 지시 2026-09-22). 결과를 보는 동안 미터가 떠 있을
|
|
80
|
+
까닭이 없고, 창을 닫고 다음 판을 시작할 때도 깨끗한 자리에서 시작하는 편이 낫다.
|
|
81
|
+
아래 설정 창 동기화와 같은 결이다 — 의도한 동기화라 set-state-in-effect 를 연다. */
|
|
82
|
+
/* eslint-disable-next-line react-hooks/set-state-in-effect */
|
|
83
|
+
useEffect(() => { if (resultOpen) {
|
|
84
|
+
setShowMeters(false);
|
|
85
|
+
set미터자리(null);
|
|
86
|
+
} }, [resultOpen]);
|
|
87
|
+
const 미터들 = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }));
|
|
62
88
|
/* 설정 창은 판 밖(body)으로 포털된다 — 열리면 초점을 창 첫 칸으로, 닫히면 연 단추로(useFloatingFocus 참고) */
|
|
63
89
|
const 창열림 = useNoiseSettingBtn && open;
|
|
64
90
|
useFloatingFocus(창열림, () => (typeof document !== 'undefined' ? document.querySelector('.noise-settings__header')?.closest('.tooltip-content') ?? null : null), () => {
|
|
@@ -69,5 +95,5 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
|
|
|
69
95
|
});
|
|
70
96
|
return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick,
|
|
71
97
|
/* 여닫이 — aria-pressed(selected) 대신 aria-expanded. 보이는 선택 모양은 같은 클래스로 */
|
|
72
|
-
className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정')
|
|
98
|
+
className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", shape: "pill", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정') }) }) })] })) : null, useNoiseSettingBtn && (_jsx("div", { ref: 미터토글ref, className: "pp-bottom__meter-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, "aria-expanded": 폰 ? showMeters : undefined, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') }) })), useNoiseSettingBtn && showMeters && !폰 && (_jsx("div", { className: "pp-bottom__meters", children: 미터들 }))] }), useNoiseSettingBtn && showMeters && 폰 && 미터자리 && (_jsx(Tooltip, { anchorRect: 미터자리, onClose: 미터닫기, ariaLabel: t('training.meter.toggle', '오디오 미터 표시'), children: _jsx("div", { className: "pp-bottom__meters pp-bottom__meters--floating", children: 미터들 }) })), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
|
|
73
99
|
}
|
|
@@ -326,3 +326,15 @@
|
|
|
326
326
|
.audio-player__segments { display: flex; align-items: center; height: 100%; width: 100%; position: relative; gap: var(--bar-gap, 3px); }
|
|
327
327
|
.audio-player__segment { position: relative; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: auto; cursor: pointer; padding: 10px 0; }
|
|
328
328
|
.audio-player__segment-inner { display: flex; align-items: center; gap: var(--bar-gap, 3px); height: 100%; width: 100%; justify-content: center; }
|
|
329
|
+
|
|
330
|
+
/* **재생 중인 재생/멈춤 단추가 호버처럼 보였다.**
|
|
331
|
+
`aria-pressed="true"` 에 `transparent-white` 변형이 흰 배경을 칠한다(Button.css) — 파란 판
|
|
332
|
+
위에서 그 하나만 흰 알약이 되어, 옆의 이전·정지·다시듣기·다음·완료(모두 투명)와 달라
|
|
333
|
+
마우스를 얹은 것처럼 읽혔다(말귀 실측 2026-09-22: 재생 중 rgb(251,253,255) vs 나머지 투명).
|
|
334
|
+
**`aria-pressed` 는 그대로 둔다** — 상태를 오가는 단추라 낭독기에 필요하다. 여기서는 색만
|
|
335
|
+
제자리로 돌린다. 상태는 아이콘(▶ ↔ ⏸)이 이미 말한다(채점 슬롯에서 쓴 것과 같은 결).
|
|
336
|
+
`:hover`·`:focus-visible` 은 건드리지 않는다 — 그건 진짜 손이 닿은 표시다. */
|
|
337
|
+
.audio-player__buttons .button--variant-transparent-white[aria-pressed='true']:not(:hover):not(:focus-visible) {
|
|
338
|
+
background: transparent;
|
|
339
|
+
color: #fbfdff;
|
|
340
|
+
}
|
|
@@ -25,6 +25,24 @@
|
|
|
25
25
|
overflow: hidden;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/* **좁은 화면에서는 "남는 높이에 맞춰 줄이기" 를 포기한다.**
|
|
29
|
+
폰에서는 '표 설정' 패널이 세로로 쌓여 본문보다 커진다(말귀 375px 실측: 설정 566px ·
|
|
30
|
+
본문 480px). 그 패널은 제 속 높이 아래로 안 줄어들므로 남는 높이가 **0** 이 되고,
|
|
31
|
+
`flex: 1; min-height: 0` 인 표 칸이 그 0 을 받는다 — 그 높이를 읽은 스케일러가 배율을
|
|
32
|
+
0 으로 내 **표가 화면에서 사라졌다**. 칸이 없으니 채점을 할 수가 없다.
|
|
33
|
+
여기서는 설정과 표를 그대로 세로로 잇고, 굴림은 이미 한 층 위 `.popup-content` 가 쥐고
|
|
34
|
+
있으므로 그쪽에 맡긴다. 표에는 밑높이를 줘 어느 경우에도 보이게 한다. */
|
|
35
|
+
@media (max-width: 767px) {
|
|
36
|
+
.word-table-popup .popup-content__inner,
|
|
37
|
+
.word-table-popup__body {
|
|
38
|
+
min-height: auto;
|
|
39
|
+
}
|
|
40
|
+
.word-table-popup__table {
|
|
41
|
+
flex: 0 0 auto;
|
|
42
|
+
min-height: 320px;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
/* 페이지 분할 — 페이지별 단어표를 세로로 쌓고, 넘치면 스크롤(fitHeight 미적용, 각 페이지=한 용지) */
|
|
29
47
|
.word-table-popup__pages {
|
|
30
48
|
flex: 1;
|
|
@@ -262,8 +262,32 @@
|
|
|
262
262
|
.phoneme-select-block__cells {
|
|
263
263
|
flex-wrap: wrap;
|
|
264
264
|
}
|
|
265
|
-
|
|
265
|
+
/* **줄을 바꾸게 두고, 줄어들지는 않게 한다.** 줄어드는 것은 감싸개(`__cell`)다 —
|
|
266
|
+
flex 항목은 기본으로 줄어들어 한 줄에 여럿이 서면 폭이 먼저 깎이고, 안의 알약은
|
|
267
|
+
그 폭의 100% 라 함께 눌린다. 폰에서 칸이 33px 까지 눌려 높이(64)만 남은 **세로 알약**이
|
|
268
|
+
됐고 그 안의 자모 셋이 10px 짜리 막대로 섰다(말귀 375px 실측 2026-09-22).
|
|
269
|
+
위 `flex-wrap: wrap` 이 이미 있으므로, 안 줄어들면 대신 아랫줄로 내려간다. */
|
|
270
|
+
/* 묶음(대립쌍의 한 쪽)도 바깥 폭을 물려준다 — 이것이 없으면 그 안의 칸이 다시
|
|
271
|
+
묶음의 속 내용으로 폭을 잡아 같은 순환에 빠진다(실측: 묶음 안 칸만 44px 로 남았다). */
|
|
272
|
+
.phoneme-select-block__cell-group {
|
|
273
|
+
width: min(240px, 100%);
|
|
274
|
+
flex-shrink: 0;
|
|
275
|
+
}
|
|
276
|
+
.phoneme-select-block__cell-group > .phoneme-select-block__cell {
|
|
277
|
+
flex: 1 1 auto;
|
|
278
|
+
min-width: 0;
|
|
279
|
+
width: auto;
|
|
280
|
+
}
|
|
281
|
+
.phoneme-select-block__cell {
|
|
282
|
+
/* **폭은 감싸개가 정한다.** 알약에만 `min(240px, 100%)` 을 주면 그 100% 가 감싸개 폭을
|
|
283
|
+
되참조하는데, 감싸개 폭은 다시 알약의 **속 내용**으로 정해진다 — 빈 칸에서는 속이
|
|
284
|
+
거의 없어 44px 로 쪼그라들었다(말귀 375px 실측: 33×64 세로 알약, 자모 셋이 10px 막대).
|
|
285
|
+
감싸개가 바깥(`__cells`, 247px)을 기준으로 폭을 잡으면 순환이 끊긴다. */
|
|
266
286
|
width: min(240px, 100%);
|
|
287
|
+
flex-shrink: 0;
|
|
288
|
+
}
|
|
289
|
+
.phoneme-select-block__cell-circle {
|
|
290
|
+
width: 100%;
|
|
267
291
|
height: 64px;
|
|
268
292
|
}
|
|
269
293
|
.phoneme-select-block__cell-jamo-part {
|
|
@@ -245,6 +245,14 @@
|
|
|
245
245
|
transform-origin: center center;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/* **이미지가 있으면 채점 줄 자리를 비워 둔다.**
|
|
249
|
+
채점 표시(`.word-table-cell__score`)는 칸 바닥에서 16px 위에 떠 있는데, 이미지 칸은 내용이
|
|
250
|
+
그만큼 아래까지 내려와 발음 글자와 겹쳤다(데스크톱 실측 2026-09-22: 세로 23px · 가로 40px).
|
|
251
|
+
글자만 있는 칸은 56px 떨어져 멀쩡하므로 그쪽은 건드리지 않는다. */
|
|
252
|
+
.word-table-cell--with-image .word-table-cell__inner {
|
|
253
|
+
padding-bottom: 48px;
|
|
254
|
+
}
|
|
255
|
+
|
|
248
256
|
.word-table-cell__word {
|
|
249
257
|
color: var(--color-black);
|
|
250
258
|
text-align: center;
|
|
@@ -441,6 +449,17 @@
|
|
|
441
449
|
display: inline-flex;
|
|
442
450
|
}
|
|
443
451
|
|
|
452
|
+
/* **손가락으로 쓰는 기기에서는 셋을 늘 보인다.**
|
|
453
|
+
위 규칙은 `:hover` 와 `:focus-within` 에 기대는데, 폰·태블릿에는 hover 가 없다 —
|
|
454
|
+
안 매긴 칸의 단추 셋이 영영 0×0 이라 **채점을 시작할 수가 없었다**(말귀 실측 2026-09-22:
|
|
455
|
+
여섯 칸 중 마우스가 얹힌 하나만 128×40, 나머지 0×0). 창 아래 안내는 "칸을 눌러 매기세요"
|
|
456
|
+
라고 적혀 있지만 매기는 것은 슬롯 단추뿐이라, 보이지 않으면 길이 없다. */
|
|
457
|
+
@media (hover: none) {
|
|
458
|
+
.word-table-cell__score-slot {
|
|
459
|
+
display: inline-flex;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
444
463
|
/* 단추 모양은 `Button` 컴포넌트가 낸다 — 여기서 다시 그리지 않는다.
|
|
445
464
|
한때 이 자리에 직접 만든 원형 단추 CSS 가 있었는데, 그러면 앱의 단추와 크기·
|
|
446
465
|
포커스 링·hover 규칙이 따로 놀았다. */
|
|
@@ -137,6 +137,14 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
137
137
|
// (Popup·CrosswordGame·Tooltip 과 같은 처리).
|
|
138
138
|
if (typeof ResizeObserver === 'undefined')
|
|
139
139
|
return;
|
|
140
|
+
/* **1 을 넘겨도 천장을 씌우지 않는다 — 일부러 둔 것이다.**
|
|
141
|
+
기준값(refW·refH)은 안쪽 상자(280×380)보다 작아서, 이미지가 없는 칸에서는 배율이 1 을
|
|
142
|
+
넘는다(실측 1.39583 — 가 · 가격 · 가격표 · 가계 모두 같다. 이미지가 있는 칸은 0.846).
|
|
143
|
+
그러면 안쪽 상자가 칸 밖으로 나가 `overflow: hidden` 에 잘리지만, **잘리는 부분은 빈
|
|
144
|
+
여백**이라 글자는 온전히 보인다(칸 335×339 · 상자 391×530). 천장을 씌우면 잘림은
|
|
145
|
+
없어지지만 글자만 있는 칸의 글자가 이미지 칸 수준으로 작아진다 — 채점표는 치료사가
|
|
146
|
+
고객에게 **보여 주며** 쓰는 물건이라 큰 글자가 이득이다(사용자 결정 2026-09-22).
|
|
147
|
+
잘림이 눈에 띄는 자리가 생기면 그때 다시 본다. */
|
|
140
148
|
const obs = new ResizeObserver(([entry]) => {
|
|
141
149
|
const { width, height } = entry.contentRect;
|
|
142
150
|
setScale(Math.min(width / refW, height / refH));
|
|
@@ -144,7 +152,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
144
152
|
obs.observe(el);
|
|
145
153
|
return () => obs.disconnect();
|
|
146
154
|
}, [refW, refH]);
|
|
147
|
-
return (_jsxs("div", { ref: outerRef, className:
|
|
155
|
+
return (_jsxs("div", { ref: outerRef, className: cx('word-table-cell', hasImage ? 'word-table-cell--with-image' : ''), children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: (e) => { if (!isTouchPointer(e))
|
|
148
156
|
handleInfoEnter(); }, onPointerLeave: (e) => { if (!isTouchPointer(e))
|
|
149
157
|
handleInfoLeave(); },
|
|
150
158
|
// 이 div 자체는 포커스 대상이 아니지만 안의 Button이 포커스를 받으면
|
|
@@ -187,7 +195,10 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
|
|
|
187
195
|
/* 세 단추를 늘 함께 그린다. 매기지 않은 칸은 통째로 숨어 있다가 마우스를 올리면
|
|
188
196
|
나타나고, 매긴 칸은 **고른 것만** 남는다(CSS 가 나머지를 감춘다). 그래야 표를
|
|
189
197
|
훑어 "어디를 어떻게 봤는지"가 한눈에 들어온다. */
|
|
190
|
-
_jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''),
|
|
198
|
+
_jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''),
|
|
199
|
+
/* CSS 의 가운데 맞춤(translateX(-50%))을 그대로 두고 되돌림만 얹는다. 바닥 가운데를
|
|
200
|
+
기준으로 키워야 칸 아래쪽에 붙은 자리가 흔들리지 않는다. */
|
|
201
|
+
style: 채점되돌림 > 1 ? { transform: `translateX(-50%) scale(${채점되돌림})`, transformOrigin: 'bottom center' } : undefined, children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, showTooltipInfo: false, ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: (e) => { e.stopPropagation(); clickMark(cellIndex, m); } }) }, m))) }));
|
|
191
202
|
};
|
|
192
203
|
const { t } = useTranslation();
|
|
193
204
|
const isEdit = editCells !== undefined;
|
|
@@ -348,6 +359,14 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
|
|
|
348
359
|
const contentClass = cx('word-table-block__content', CUT_CLASS[cutLine], showOutline && 'word-table-block__content--outline', showDivLine && hasPairs && 'word-table-block__content--div-line');
|
|
349
360
|
// 빈칸(추가버튼)은 스케일 역보정으로 실제 크기 유지
|
|
350
361
|
const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
|
|
362
|
+
/* **채점 단추는 표와 함께 줄어들면 안 된다.**
|
|
363
|
+
○ · △ · ✕ 는 표 안에 있어 배율을 그대로 받는다 — 폰에서 배율이 0.28 이면 40px 단추가
|
|
364
|
+
11px 이 되어 손가락으로는 못 누른다(말귀 실측·셈). 빈칸이 쓰는 것과 같은 되돌림을 씌운다.
|
|
365
|
+
다만 끝까지 되돌리면(1/scale) 단추 셋(40×3 + 사이 8 = 128)이 **칸보다 넓어져** 옆 칸을
|
|
366
|
+
침범한다 — 칸 폭을 천장으로 둔다. 이 견줌은 표 좌표에서 하므로 배율이 양쪽에서 약분된다.
|
|
367
|
+
넓은 화면(배율 1)에서는 1 이라 아무것도 달라지지 않는다. */
|
|
368
|
+
const 채점행폭 = buttonSize * MARKS.length + 4 * (MARKS.length - 1);
|
|
369
|
+
const 채점되돌림 = Math.max(1, Math.min(1 / scale, (width / cols) / 채점행폭));
|
|
351
370
|
const grid = (_jsxs("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: [_jsx("div", {
|
|
352
371
|
/* title 로 Section 에 감쌀 때는 className 을 바깥 한 곳에만 — 두 곳에 붙어 소비자 스타일이 두 번 걸렸다 */
|
|
353
372
|
className: cx('word-table', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: 내용ref, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
|
|
@@ -16,6 +16,12 @@ export interface ButtonProps {
|
|
|
16
16
|
* 크기. 숫자는 CSS 의 크기 사다리(.button24 … .button72)와 짝을 이룬다 — **사다리에
|
|
17
17
|
* 없는 숫자를 넣으면 그 버튼은 크기·모서리 규칙을 하나도 못 받는다.**
|
|
18
18
|
*
|
|
19
|
+
* **숫자 size 는 좁은 화면에서 한 급씩 줄어든다.** 1024px 이하에서 56→50 · 48→44 · 40→36 · 32→30,
|
|
20
|
+
* 768px 이하에서 56→46 · 48→40 · 40→34 · **32→28** 이다. `'Keyboard'`(32px)·`'Word'`(72px) 는
|
|
21
|
+
* **줄어들지 않는다** — 그 둘에는 반응형 규칙이 없다. 그래서 폰에서 `size={32}` 와 `size="Keyboard"` 를
|
|
22
|
+
* 한 줄에 세우면 28 과 32 로 4px 어긋난다(말귀 '표 설정' 패널 실측 2026-09-22). 나란히 세워야 하면
|
|
23
|
+
* 양쪽 다 숫자 size 를 쓰거나 양쪽 다 Keyboard 를 쓴다.
|
|
24
|
+
*
|
|
19
25
|
* 목록이 사실과 두 군데 어긋나 있었다. 72 는 CSS 에 있는데 목록에 없었고, 52 는
|
|
20
26
|
* 목록에도 CSS 에도 없는데 BottomNav 가 쓰고 있었다(그쪽은 자체 클래스가 !important
|
|
21
27
|
* 로 크기를 다시 잡아 겉으로는 멀쩡했다). 둘 다 사실에 맞춘다 — 52 는 CSS 에 칸을
|
|
@@ -13,9 +13,25 @@ export function useFitScale(contentWidth, contentHeight) {
|
|
|
13
13
|
const scalerRef = useRef(null);
|
|
14
14
|
const [scale, setScale] = useState(1);
|
|
15
15
|
const [offset, setOffset] = useState(0);
|
|
16
|
+
/* **잰 값이 0 이면 그 잣대는 안 쓴다.**
|
|
17
|
+
높이까지 맞출 때는 부모를 재는데, 그 부모가 제 높이를 안 가진 자리(높이가 내용으로
|
|
18
|
+
정해지는 통)에서는 0 이 나온다 — 그러면 배율이 0 이 되어 표가 화면에서 **아예 사라졌다**
|
|
19
|
+
(말귀 375px 채점 창 실측 2026-09-22: scale(0), 칸이 없으니 채점을 할 수가 없었다).
|
|
20
|
+
0 을 "맞출 수 없다" 로 읽고 그 잣대만 빼면, 폭만으로 줄여 세로로는 굴려 보게 된다 —
|
|
21
|
+
작아도 보이는 쪽이 없는 것보다 낫다. */
|
|
16
22
|
const updateScale = useCallback((containerWidth, containerHeight) => {
|
|
23
|
+
/* **폭을 못 재면 아무것도 바꾸지 않는다** — 지난 값을 지킨다. 숨겨졌거나(디스플레이 none)
|
|
24
|
+
아직 자리를 못 잡은 순간에 0 이 오는데, 그걸로 새로 정하면 배율이 0(사라짐)이나 1(넘침)로
|
|
25
|
+
튀고 그 값이 다음 측정까지 남는다. */
|
|
26
|
+
if (!(containerWidth > 0))
|
|
27
|
+
return;
|
|
28
|
+
/* 높이는 **잴 수 있을 때만** 잣대로 쓴다. 높이까지 맞출 때는 부모를 재는데, 그 부모가 제
|
|
29
|
+
높이를 안 가진 자리에서는 0 이 나온다 — 그대로 나누면 배율이 0 이 되어 표가 화면에서
|
|
30
|
+
아예 사라졌다(말귀 375px 채점 창). 게다가 한 번 0 이 되면 축소된 내용의 높이도 0 이라
|
|
31
|
+
**부모도 계속 0** 이 되어 어느 크기로 돌려도 안 돌아왔다(창을 열어 둔 채 기기를 돌린
|
|
32
|
+
경우 — 태블릿을 돌리면 표가 사라졌다). 0 을 "못 잼" 으로 읽어 그 고리를 끊는다. */
|
|
17
33
|
const sw = containerWidth / contentWidth;
|
|
18
|
-
const sh = contentHeight ? containerHeight / contentHeight : Infinity;
|
|
34
|
+
const sh = contentHeight && containerHeight > 0 ? containerHeight / contentHeight : Infinity;
|
|
19
35
|
const s = Math.min(1, sw, sh);
|
|
20
36
|
setScale(s);
|
|
21
37
|
setOffset(Math.max(0, (containerWidth - contentWidth * s) / 2));
|
|
@@ -108,7 +108,8 @@
|
|
|
108
108
|
"scheduled": "Scheduled",
|
|
109
109
|
"noVisit": "No Show"
|
|
110
110
|
},
|
|
111
|
-
"periodSelect": "Period unit"
|
|
111
|
+
"periodSelect": "Period unit",
|
|
112
|
+
"visitCount": "{{count}} visit(s)"
|
|
112
113
|
},
|
|
113
114
|
"passCard": {
|
|
114
115
|
"planSuffix": "Plan",
|
|
@@ -166,6 +167,7 @@
|
|
|
166
167
|
"notifications": "Notifications",
|
|
167
168
|
"account": "Account",
|
|
168
169
|
"help": "Help",
|
|
170
|
+
"feedback": "Report a bug",
|
|
169
171
|
"debug": "Debug",
|
|
170
172
|
"coupons": "Coupons",
|
|
171
173
|
"topic": "By Topic",
|
|
@@ -805,7 +807,7 @@
|
|
|
805
807
|
"pickNoMatch": "No matching customer.",
|
|
806
808
|
"pickAgain": "Choose another",
|
|
807
809
|
"scoringFor": "Scoring {{name}}",
|
|
808
|
-
"none": "Tap a cell to mark
|
|
810
|
+
"none": "Tap ○ · △ · ✕ under a cell to mark it. With a mouse, hover the cell to reveal them.",
|
|
809
811
|
"tally": "{{marked}} scored · ○ {{o}} · △ {{triangle}} · ✕ {{x}} · {{rate}}%",
|
|
810
812
|
"save": "Save score",
|
|
811
813
|
"saved": "Saved {{count}} marks for {{name}}.",
|
|
@@ -106,7 +106,8 @@
|
|
|
106
106
|
"scheduled": "예약",
|
|
107
107
|
"noVisit": "미방문"
|
|
108
108
|
},
|
|
109
|
-
"periodSelect": "기간 단위"
|
|
109
|
+
"periodSelect": "기간 단위",
|
|
110
|
+
"visitCount": "방문 {{count}}건"
|
|
110
111
|
},
|
|
111
112
|
"passCard": {
|
|
112
113
|
"planSuffix": "이용권",
|
|
@@ -164,6 +165,7 @@
|
|
|
164
165
|
"notifications": "알림",
|
|
165
166
|
"account": "계정",
|
|
166
167
|
"help": "도움말",
|
|
168
|
+
"feedback": "버그신고",
|
|
167
169
|
"debug": "디버그",
|
|
168
170
|
"coupons": "쿠폰",
|
|
169
171
|
"topic": "주제별 듣기",
|
|
@@ -803,7 +805,7 @@
|
|
|
803
805
|
"pickNoMatch": "찾는 고객이 없습니다.",
|
|
804
806
|
"pickAgain": "고객 다시 고르기",
|
|
805
807
|
"scoringFor": "{{name}} 님 채점 중",
|
|
806
|
-
"none": "
|
|
808
|
+
"none": "칸 아래 ○ · △ · ✕ 를 눌러 매기세요. 마우스로는 칸에 손을 얹으면 나타납니다.",
|
|
807
809
|
"tally": "{{marked}}칸 채점 · ○ {{o}} · △ {{triangle}} · ✕ {{x}} · 정답률 {{rate}}%",
|
|
808
810
|
"save": "채점 저장",
|
|
809
811
|
"saved": "{{name}} 님의 채점 {{count}}칸을 저장했습니다.",
|