@ihabdevteam/core 0.135.0 → 0.136.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 +75 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +19 -3
- package/dist/packages/core/src/components/Alert.js +1 -1
- package/dist/packages/core/src/components/Button.css +5 -0
- package/dist/packages/core/src/components/Button.js +3 -3
- package/dist/packages/core/src/components/Checkbox.js +10 -2
- package/dist/packages/core/src/components/CodeBox.js +2 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +2 -0
- package/dist/packages/core/src/components/IconGridMenu.js +25 -22
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
- package/dist/packages/core/src/components/Popup/Popup.css +9 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
- package/dist/packages/core/src/components/Popup/Popup.js +2 -2
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
- package/dist/packages/core/src/components/ProgressBar.js +5 -2
- package/dist/packages/core/src/components/ProgressIndicator.js +6 -1
- package/dist/packages/core/src/components/Rating.js +3 -1
- package/dist/packages/core/src/components/SideNav.js +6 -3
- package/dist/packages/core/src/components/SplashLogo.js +1 -2
- package/dist/packages/core/src/components/StatChart.js +1 -1
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
- package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +5 -3
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +4 -3
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
- package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
- package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
- package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
- package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
- package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
- package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
- package/dist/packages/core/src/context/I18nProvider.js +12 -3
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
- package/dist/packages/core/src/i18n/locales/en.json +1 -0
- package/dist/packages/core/src/i18n/locales/ko.json +1 -0
- package/dist/packages/core/src/utils/date.js +26 -5
- package/dist/packages/core/src/utils/speak.js +23 -3
- package/dist/packages/core/src/utils/textSort.js +12 -1
- package/dist/packages/core/src/utils/ttsConfig.js +10 -8
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -773,6 +773,12 @@ export declare interface ButtonProps {
|
|
|
773
773
|
title?: string;
|
|
774
774
|
items: string[];
|
|
775
775
|
} | null;
|
|
776
|
+
/**
|
|
777
|
+
* 누르는 단추가 아니라 **툴팁을 보여 주는 칩** 이다 — 커서를 손가락이 아닌 기본 화살표로 둔다.
|
|
778
|
+
* 모양(바탕·hover)과 포커스·툴팁은 그대로다. `onClick` 없이 `showTooltipInfo`·`tooltipDescription` 만 다는
|
|
779
|
+
* 자리(점수 칩 등)에 쓴다. 막힌 단추(`disabled`)와는 다르다 — 흐려지지 않는다.
|
|
780
|
+
*/
|
|
781
|
+
tooltipOnly?: boolean;
|
|
776
782
|
iconSize?: 'small' | 'medium' | 'large';
|
|
777
783
|
iconColor?: string;
|
|
778
784
|
/**
|
|
@@ -1369,7 +1375,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1369
1375
|
*
|
|
1370
1376
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1371
1377
|
*/
|
|
1372
|
-
export declare const CORE_VERSION = "0.
|
|
1378
|
+
export declare const CORE_VERSION = "0.136.0";
|
|
1373
1379
|
|
|
1374
1380
|
/** @public */
|
|
1375
1381
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -4150,9 +4156,17 @@ export declare interface PopupProps {
|
|
|
4150
4156
|
* - `small` 480px (확인/알림류)
|
|
4151
4157
|
* - `medium` 680px (입력 폼)
|
|
4152
4158
|
* - `large` 1080px (리스트/다중 콘텐츠)
|
|
4159
|
+
* - `xlarge` 1320px, 폭은 화면 - 80px(좌우 40px) — 훈련 카드 만들기처럼 화면을 넓게 쓰는 창
|
|
4160
|
+
* - `fit` 내용 폭에 맞춘다 — 최소 480px·최대 1140px(둘 다 화면 폭 - 40px 을 넘지 않는다).
|
|
4161
|
+
* 한 줄에 카드가 몇 장 들어가느냐로 폭이 정해지는 목록 창에 쓴다.
|
|
4153
4162
|
* 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
|
|
4154
4163
|
*/
|
|
4155
|
-
size?: 'small' | 'medium' | 'large';
|
|
4164
|
+
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fit';
|
|
4165
|
+
/**
|
|
4166
|
+
* 쌓이는 층. 기본 `modal`(`--z-modal`) 은 훈련 페이지 팝업(`--z-page-popup`) 위에 뜬다.
|
|
4167
|
+
* `belowPagePopup` 은 그 **바로 아래** 에 깐다 — 이 창에서 연 훈련 팝업이 이 창을 덮어야 할 때(실행 창 등).
|
|
4168
|
+
*/
|
|
4169
|
+
layer?: 'modal' | 'belowPagePopup';
|
|
4156
4170
|
/**
|
|
4157
4171
|
* 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
|
|
4158
4172
|
*
|
|
@@ -6863,8 +6877,10 @@ export declare interface TrainingBlockButtonProps {
|
|
|
6863
6877
|
* - `small`: 120×120px 축소 카드 (행 펼치기 영역 등 좁은 공간용)
|
|
6864
6878
|
* - `list`: 너비 200px · 최소 높이 68px 가로형 항목
|
|
6865
6879
|
* - `compact`: 툴팁 등 좁은 공간용 소형 리스트 항목 (전체 너비 × 높이 40px, 작은 아이콘)
|
|
6880
|
+
* - `row`: 전체 너비 가로 행 — [아이콘][유형·제목][문항수]. 미리 듣기 단추는 없다.
|
|
6881
|
+
* 좁은 화면에서 정사각형 카드 대신 쓴다(카드는 폭이 줄면 내용이 눌린다). 유형 줄은 `showTypeLabel` 일 때.
|
|
6866
6882
|
*/
|
|
6867
|
-
size?: 'square' | 'small' | 'list' | 'compact';
|
|
6883
|
+
size?: 'square' | 'small' | 'list' | 'compact' | 'row';
|
|
6868
6884
|
renderPopup?: (props: TrainingBlockButtonPopupProps) => ReactNode;
|
|
6869
6885
|
completedIds?: Set<string>;
|
|
6870
6886
|
/** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지이고, `hideCheckBadge` 면 숨는다. */
|
|
@@ -24,5 +24,5 @@ export default function Alert({ tone = 'info', title, message, children, dismiss
|
|
|
24
24
|
const cls = cx('alert', `alert--${tone}`, className);
|
|
25
25
|
const hasTitle = Boolean(title);
|
|
26
26
|
const hasMessage = Boolean(message || children);
|
|
27
|
-
return (_jsxs("div", { className: cls, role: "alert", children: [_jsxs("div", { className: "alert__body", children: [_jsxs("div", { className: "alert__header", children: [_jsx(Icon, { icon: TONE_ICON[tone], size: 20, className: "alert__icon" }), hasTitle && _jsx("span", { className: "alert__title body-2", children: title }), !hasTitle && hasMessage && _jsx("div", { className: "alert__message body-3", children: message
|
|
27
|
+
return (_jsxs("div", { className: cls, role: "alert", children: [_jsxs("div", { className: "alert__body", children: [_jsxs("div", { className: "alert__header", children: [_jsx(Icon, { icon: TONE_ICON[tone], size: 20, className: "alert__icon" }), hasTitle && _jsx("span", { className: "alert__title body-2", children: title }), !hasTitle && hasMessage && _jsx("div", { className: "alert__message body-3", children: message || children })] }), hasTitle && hasMessage && (_jsx("div", { className: "alert__message alert__message--indented body-3", children: message || children }))] }), dismissible && (_jsx("button", { type: "button", className: "alert__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }))] }));
|
|
28
28
|
}
|
|
@@ -8,7 +8,7 @@ import InfoTooltip from "./Tooltip/InfoTooltip";
|
|
|
8
8
|
import DescriptionTooltip from "./Tooltip/DescriptionTooltip";
|
|
9
9
|
/** @public */
|
|
10
10
|
export default function Button(props) {
|
|
11
|
-
const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', selected, toggleable = false, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
11
|
+
const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', selected, toggleable = false, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
12
12
|
const sizeKey = typeof size === 'string' && isNaN(Number(size))
|
|
13
13
|
? size.toLowerCase()
|
|
14
14
|
: String(Number(size) || 48);
|
|
@@ -85,7 +85,7 @@ export default function Button(props) {
|
|
|
85
85
|
const ariaPressed = (toggleable || typeof selected === 'boolean')
|
|
86
86
|
? (isSelected ? 'true' : 'false')
|
|
87
87
|
: undefined;
|
|
88
|
-
const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, className);
|
|
88
|
+
const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, tooltipOnly ? 'button--tooltip-only' : null, className);
|
|
89
89
|
const iconClassName = `button__icon button__icon--${iconSize}`;
|
|
90
90
|
const subLabels = Array.isArray(subLabel) ? subLabel : (subLabel !== undefined ? [subLabel] : []);
|
|
91
91
|
const renderLabelContent = () => {
|
|
@@ -180,7 +180,7 @@ export default function Button(props) {
|
|
|
180
180
|
const Root = (isLink ? 'a' : 'button');
|
|
181
181
|
return (_jsxs(_Fragment, { children: [_jsx(Root, { ...restProps, ref: btnRef, ...(isLink
|
|
182
182
|
? { href, target, rel: rel ?? (target === '_blank' ? 'noreferrer' : undefined) }
|
|
183
|
-
: { type: htmlType, disabled: disabledProp && !onDisabledClick }), className: classes, "aria-pressed": ariaPressed, "aria-label": ariaLabelString, "aria-disabled":
|
|
183
|
+
: { type: htmlType, disabled: disabledProp && !onDisabledClick }), className: classes, "aria-pressed": ariaPressed, "aria-label": ariaLabelString, "aria-disabled": disabledProp && (isLink || !!onDisabledClick) ? true : undefined, style: style, onClick: handleClickInternal, onMouseLeave: handleMouseLeave, onPointerEnter: (e) => {
|
|
184
184
|
showTooltipFn();
|
|
185
185
|
if (!disabledProp && typeof rest.onPointerEnter === 'function')
|
|
186
186
|
rest.onPointerEnter(e);
|
|
@@ -7,9 +7,17 @@ import { useEffect, useId, useRef } from 'react';
|
|
|
7
7
|
export default function Checkbox({ checked = false, onChange, disabled, indeterminate, label, size = 'medium', className, title, ariaLabel, }) {
|
|
8
8
|
const ref = useRef(null);
|
|
9
9
|
const id = useId();
|
|
10
|
+
/* 중간 상태는 DOM 속성이라 누르는 순간 브라우저가 false 로 바꾼다. React 는 checked 만 되돌리므로 prop 이 그대로
|
|
11
|
+
true 여도(비활성 행이 있어 전체 선택이 안 되는 등) 빈 칸으로 남았다 — 누른 뒤와 다시 그릴 때마다 다시 넣는다. */
|
|
12
|
+
const indeterminateRef = useRef(!!indeterminate);
|
|
13
|
+
indeterminateRef.current = !!indeterminate;
|
|
10
14
|
useEffect(() => {
|
|
11
15
|
if (ref.current)
|
|
12
16
|
ref.current.indeterminate = !!indeterminate;
|
|
13
|
-
}
|
|
14
|
-
return (_jsxs("label", { className: cx('checkbox', size === 'small' && 'checkbox--small', size === 'large' && 'checkbox--large', disabled && 'checkbox--disabled', className), htmlFor: id, title: title, children: [_jsx("input", { ref: ref, id: id, type: "checkbox", className: "checkbox__input", checked: checked, onChange: e =>
|
|
17
|
+
});
|
|
18
|
+
return (_jsxs("label", { className: cx('checkbox', size === 'small' && 'checkbox--small', size === 'large' && 'checkbox--large', disabled && 'checkbox--disabled', className), htmlFor: id, title: title, children: [_jsx("input", { ref: ref, id: id, type: "checkbox", className: "checkbox__input", checked: checked, onChange: e => {
|
|
19
|
+
onChange?.(e.target.checked);
|
|
20
|
+
if (ref.current)
|
|
21
|
+
ref.current.indeterminate = indeterminateRef.current;
|
|
22
|
+
}, disabled: disabled, "aria-label": !label && ariaLabel ? ariaLabel : undefined }), _jsx("span", { className: "checkbox__box" }), label && _jsx("span", { className: "checkbox__label body-3", children: label })] }));
|
|
15
23
|
}
|
|
@@ -32,7 +32,8 @@ const KEYWORDS = /^(?:const|let|var|function|return|if|else|for|while|switch|cas
|
|
|
32
32
|
function tokenize(code, lang) {
|
|
33
33
|
let s = code;
|
|
34
34
|
const tokens = [];
|
|
35
|
-
|
|
35
|
+
// 이름이 **정확히** 셸일 때만 — 부분 일치라 csharp 도 셸로 읽혀 `//` 주석은 놓치고 `#region` 이 주석이 됐다
|
|
36
|
+
const isBash = /^(?:sh|bash|zsh|shell|console)$/.test(lang.toLowerCase());
|
|
36
37
|
const push = (type, content) => tokens.push({ type, content });
|
|
37
38
|
while (s.length > 0) {
|
|
38
39
|
const ws = s.match(/^\s+/);
|
|
@@ -176,6 +176,8 @@ onComplete, trigger = 0, }) {
|
|
|
176
176
|
if (now < p.startTime) {
|
|
177
177
|
// waiting to start: keep hidden
|
|
178
178
|
node.style.display = "none";
|
|
179
|
+
// 출발 전 조각도 살아 있다 — 안 세면 첫 틀이 모든 지연보다 이를 때 곧바로 끝나 한 조각도 안 났다
|
|
180
|
+
alive = true;
|
|
179
181
|
continue;
|
|
180
182
|
}
|
|
181
183
|
// initialize origin positioning once when particle starts
|
|
@@ -16,33 +16,36 @@ function IconGridMenuButton(props) {
|
|
|
16
16
|
/** @public */
|
|
17
17
|
export default function IconGridMenu(props) {
|
|
18
18
|
const { categories = [], selected, onSelect, size = 'medium' } = props || {};
|
|
19
|
-
/* `null` 은 "고르지 않음" 이라는 제어 값이다 — 첫 카테고리로 갈음하지
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
19
|
+
/* 제어 모드면 **prop 을 그대로** 그린다. `null` 은 "고르지 않음" 이라는 제어 값이다 — 첫 카테고리로 갈음하지 않는다.
|
|
20
|
+
전에는 누르는 순간 안쪽 상태를 바꿔, 부모가 값을 안 바꾸면(같은 selected 를 다시 넘기면) 화면만 다른 단추로 남았다. */
|
|
21
|
+
const isControlled = selected !== undefined;
|
|
22
|
+
const [innerKey, setInnerKey] = useState(() => (isControlled ? null : ((categories && categories[0]?.key) ?? null)));
|
|
23
|
+
const selectedKey = isControlled ? selected : innerKey;
|
|
24
|
+
/* 비제어 모드의 첫 선택 — 목록이 **처음 생겼을 때** 한 번 고르고 부모에 알린다. 마운트 때만 보면 카테고리가 늦게
|
|
25
|
+
오는 경우(`[]` 뒤 목록) 아무것도 안 골라졌고, StrictMode 의 두 번 실행에서는 알림이 두 번 갔다.
|
|
26
|
+
제어 모드에서는 부모가 이미 값을 가지므로 에코하지 않는다(부모 상태를 덮어쓸 수 있다). */
|
|
27
|
+
const onSelectRef = useRef(onSelect);
|
|
28
|
+
useEffect(() => { onSelectRef.current = onSelect; });
|
|
29
|
+
const firstPickDoneRef = useRef(false);
|
|
30
|
+
const firstKey = (categories && categories[0]?.key) ?? null;
|
|
23
31
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
24
32
|
useEffect(() => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
if (isControlled || firstPickDoneRef.current || !firstKey)
|
|
34
|
+
return;
|
|
35
|
+
firstPickDoneRef.current = true;
|
|
36
|
+
const key = innerKey ?? firstKey;
|
|
37
|
+
if (innerKey == null)
|
|
38
|
+
setInnerKey(key);
|
|
39
|
+
onSelectRef.current?.(key);
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
}, [isControlled, firstKey]);
|
|
31
42
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
32
|
-
const initialOnSelectRef = useRef(onSelect);
|
|
33
|
-
const initialKeyRef = useRef(selectedKey);
|
|
34
|
-
const isControlledRef = useRef(selected !== undefined);
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
// 비제어(uncontrolled) 모드에서만 초기 선택을 부모에 알림.
|
|
37
|
-
// 제어 모드(selected 전달)에서는 부모가 이미 값을 가지므로 에코하면 의도치 않게 부모 상태를 덮어쓸 수 있음.
|
|
38
|
-
if (!isControlledRef.current && initialKeyRef.current && typeof initialOnSelectRef.current === 'function') {
|
|
39
|
-
initialOnSelectRef.current(initialKeyRef.current);
|
|
40
|
-
}
|
|
41
|
-
}, []);
|
|
42
43
|
const handleClick = useCallback((key) => {
|
|
43
44
|
const next = selectedKey === key ? null : key;
|
|
44
|
-
|
|
45
|
+
firstPickDoneRef.current = true;
|
|
46
|
+
if (!isControlled)
|
|
47
|
+
setInnerKey(next);
|
|
45
48
|
onSelect?.(next);
|
|
46
|
-
}, [selectedKey, onSelect]);
|
|
49
|
+
}, [selectedKey, isControlled, onSelect]);
|
|
47
50
|
return (_jsx("div", { className: `icon-grid-menu icon-grid-menu--${size}`, children: (categories || []).map((t) => (_jsx(IconGridMenuButton, { category: t, isSelected: selectedKey === t.key, onClick: () => handleClick(t.key) }, t.key))) }));
|
|
48
51
|
}
|
|
@@ -7,6 +7,7 @@ import Table from '../Table';
|
|
|
7
7
|
import Toolbar from '../Toolbar';
|
|
8
8
|
import './ListPopup.css';
|
|
9
9
|
import { 칸순 } from '../../utils/textSort';
|
|
10
|
+
import { cx } from '../../utils/cx';
|
|
10
11
|
/** @public */
|
|
11
12
|
export default function ListPopup(props) {
|
|
12
13
|
const { title, items, columns, gridTemplateColumns, searchPlaceholder, searchFn, defaultSortKey, defaultSortAsc = true, selectedId, selectedIds, onSelect, onClose, pageSizeOptions = [10, 25, 50, 100], initialPageSize, emptyText, countLabel, topContent, className = '', } = props;
|
|
@@ -104,7 +105,8 @@ export default function ListPopup(props) {
|
|
|
104
105
|
content: (col.render
|
|
105
106
|
? col.render(it)
|
|
106
107
|
: (it[col.key] ?? '')),
|
|
107
|
-
className
|
|
108
|
+
// 고른 행에서도 열 className(정렬·폭 등)을 지킨다 — 전에는 고른 표시가 그 자리를 덮어 고른 행만 칸 모양이 달라졌다
|
|
109
|
+
className: cx(col.className, isSelected && 'popup-list__row--selected'),
|
|
108
110
|
}));
|
|
109
111
|
}, empty: resolvedEmptyText })] }));
|
|
110
112
|
}
|
|
@@ -31,7 +31,7 @@ function TableThumbnail({ table }) {
|
|
|
31
31
|
const item = table.items[i];
|
|
32
32
|
if (!item)
|
|
33
33
|
return _jsx("div", { className: "load-table-popup__thumb-cell load-table-popup__thumb-cell--empty" }, i);
|
|
34
|
-
return (_jsx("div", { className: "load-table-popup__thumb-cell", children: item.words.length >= 2 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[0].label }), _jsx("span", { className: "load-table-popup__thumb-divider" }), _jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[1].label })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[0]?.label ?? item.id }), item.words[0]?.subLabel && (_jsx("span", { className: "load-table-popup__thumb-sub", children: item.words[0].subLabel }))] })) }, i));
|
|
34
|
+
return (_jsx("div", { className: "load-table-popup__thumb-cell", children: (item.words ?? []).length >= 2 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[0].label }), _jsx("span", { className: "load-table-popup__thumb-divider" }), _jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[1].label })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words?.[0]?.label ?? item.id }), item.words?.[0]?.subLabel && (_jsx("span", { className: "load-table-popup__thumb-sub", children: item.words[0].subLabel }))] })) }, i));
|
|
35
35
|
}) }));
|
|
36
36
|
}
|
|
37
37
|
const DeleteButton = ({ onClick, ariaLabel }) => {
|
|
@@ -51,10 +51,11 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
51
51
|
const [viewMode, setViewMode] = useState('grid');
|
|
52
52
|
const sorted = useMemo(() => {
|
|
53
53
|
const copy = [...tables];
|
|
54
|
+
// 저장소에서 온 표는 어긋나 있을 수 있다(savedAt 없음) — 한 표 때문에 팝업 전체가 렌더 중에 터지던 것
|
|
54
55
|
if (sortKey === 'newest')
|
|
55
|
-
return copy.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
|
56
|
+
return copy.sort((a, b) => String(b.savedAt ?? '').localeCompare(String(a.savedAt ?? '')));
|
|
56
57
|
if (sortKey === 'oldest')
|
|
57
|
-
return copy.sort((a, b) => a.savedAt.localeCompare(b.savedAt));
|
|
58
|
+
return copy.sort((a, b) => String(a.savedAt ?? '').localeCompare(String(b.savedAt ?? '')));
|
|
58
59
|
if (sortKey === 'name')
|
|
59
60
|
return copy.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
|
60
61
|
if (sortKey === 'count')
|
|
@@ -81,7 +82,7 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
81
82
|
animateThen(() => onLoad(table));
|
|
82
83
|
}, [tables, selected, onLoad]);
|
|
83
84
|
const headerActions = useMemo(() => tables.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Dropdown, { options: VIEW_OPTIONS, value: viewMode, onChange: handleSetViewMode }), _jsx(Dropdown, { options: SORT_OPTIONS, value: sortKey, onChange: handleSetSortKey })] })) : undefined, [tables.length, viewMode, sortKey, handleSetViewMode, handleSetSortKey, VIEW_OPTIONS, SORT_OPTIONS]);
|
|
84
|
-
return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", table.items.length,
|
|
85
|
+
return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' })] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: (item.words ?? []).length >= 2
|
|
85
86
|
? `${item.words[0].label} · ${item.words[1].label}`
|
|
86
|
-
: item.words[0]?.label ?? item.id }, i))) })] }),
|
|
87
|
+
: item.words?.[0]?.label ?? item.id }, i))) })] }), _jsx("span", { className: "load-table-popup__row-count caption-1", children: t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' }) }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
|
|
87
88
|
}
|
|
@@ -34,6 +34,15 @@
|
|
|
34
34
|
.popup-panel--small { max-width: 480px; }
|
|
35
35
|
.popup-panel--medium { max-width: 680px; }
|
|
36
36
|
.popup-panel--large { max-width: 1080px; }
|
|
37
|
+
.popup-panel--xlarge { width: calc(100% - 80px); max-width: 1320px; }
|
|
38
|
+
/* fit — 내용 폭. 최소·최대 모두 화면 폭 - 40px 을 넘지 않는다(좁은 화면에서 480px 로 잘리지 않게) */
|
|
39
|
+
.popup-panel--fit {
|
|
40
|
+
width: fit-content;
|
|
41
|
+
min-width: min(480px, calc(100% - 40px));
|
|
42
|
+
max-width: min(1140px, calc(100% - 40px));
|
|
43
|
+
}
|
|
44
|
+
/* 층 — 훈련 페이지 팝업 바로 아래(그 창이 이 창을 덮는다). inline 은 제 자리 규칙을 따른다 */
|
|
45
|
+
.popup.popup--below-page-popup:not(.popup--inline) { z-index: calc(var(--z-page-popup) - 1); }
|
|
37
46
|
.popup-close-float {
|
|
38
47
|
position: absolute;
|
|
39
48
|
top: 8px;
|
|
@@ -70,9 +70,17 @@ export interface PopupProps {
|
|
|
70
70
|
* - `small` 480px (확인/알림류)
|
|
71
71
|
* - `medium` 680px (입력 폼)
|
|
72
72
|
* - `large` 1080px (리스트/다중 콘텐츠)
|
|
73
|
+
* - `xlarge` 1320px, 폭은 화면 - 80px(좌우 40px) — 훈련 카드 만들기처럼 화면을 넓게 쓰는 창
|
|
74
|
+
* - `fit` 내용 폭에 맞춘다 — 최소 480px·최대 1140px(둘 다 화면 폭 - 40px 을 넘지 않는다).
|
|
75
|
+
* 한 줄에 카드가 몇 장 들어가느냐로 폭이 정해지는 목록 창에 쓴다.
|
|
73
76
|
* 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
|
|
74
77
|
*/
|
|
75
|
-
size?: 'small' | 'medium' | 'large';
|
|
78
|
+
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fit';
|
|
79
|
+
/**
|
|
80
|
+
* 쌓이는 층. 기본 `modal`(`--z-modal`) 은 훈련 페이지 팝업(`--z-page-popup`) 위에 뜬다.
|
|
81
|
+
* `belowPagePopup` 은 그 **바로 아래** 에 깐다 — 이 창에서 연 훈련 팝업이 이 창을 덮어야 할 때(실행 창 등).
|
|
82
|
+
*/
|
|
83
|
+
layer?: 'modal' | 'belowPagePopup';
|
|
76
84
|
/**
|
|
77
85
|
* 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
|
|
78
86
|
*
|
|
@@ -13,7 +13,7 @@ const ANIM_MS = 260;
|
|
|
13
13
|
/** @public */
|
|
14
14
|
export default function Popup(props) {
|
|
15
15
|
const { t } = useTranslation();
|
|
16
|
-
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, height, resetScrollKey } = props || {};
|
|
16
|
+
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, resetScrollKey } = props || {};
|
|
17
17
|
const [closing, setClosing] = useState(false);
|
|
18
18
|
// 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
|
|
19
19
|
// 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
|
|
@@ -154,7 +154,7 @@ export default function Popup(props) {
|
|
|
154
154
|
if (pressed && e.target === e.currentTarget)
|
|
155
155
|
closeWithAnimation();
|
|
156
156
|
};
|
|
157
|
-
const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
|
|
157
|
+
const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${layer === 'belowPagePopup' ? 'popup--below-page-popup' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
|
|
158
158
|
/* 바탕을 눌러도 포커스는 판 안에 둔다 — 닫히지 않는 바탕(closeOnBackdrop={false}·closeDisabled·beforeClose 가
|
|
159
159
|
거절)을 누르면 포커스가 body 로 떨어져, 이어서 누른 Tab 이 뒤 화면으로 나갔다(가둠은 판 안의 키만 듣는다).
|
|
160
160
|
mousedown 의 기본 동작(포커스 옮김)만 막고 click 은 그대로 온다. */
|
|
@@ -77,10 +77,12 @@ export function visitItemKey(v) {
|
|
|
77
77
|
* 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
|
|
78
78
|
*/
|
|
79
79
|
export function formatDuration(sec, durationStr) {
|
|
80
|
-
|
|
80
|
+
// 초 단위로 반올림한 뒤 나눈다 — 90.5 가 "1분 30.5초", 59.999 가 "59.999초" 로 찍혔다
|
|
81
|
+
const 초 = Number.isFinite(sec) ? Math.round(sec) : 0;
|
|
82
|
+
if (!초 || 초 <= 0)
|
|
81
83
|
return '-';
|
|
82
|
-
const m = Math.floor(
|
|
83
|
-
const s =
|
|
84
|
+
const m = Math.floor(초 / 60);
|
|
85
|
+
const s = 초 % 60;
|
|
84
86
|
if (durationStr)
|
|
85
87
|
return durationStr(m, s);
|
|
86
88
|
// 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
|
|
@@ -24,6 +24,9 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
|
|
|
24
24
|
const safeMax = Number.isFinite(max) && max > 0 ? max : 1;
|
|
25
25
|
const clamped = Math.max(0, Math.min(Number.isFinite(value) ? value : 0, safeMax));
|
|
26
26
|
const pct = (clamped / safeMax) * 100;
|
|
27
|
+
/* 글자·aria 의 최댓값은 **받은 값** 이다(0 이하·못 읽는 값은 0) — safeMax 는 나눗셈을 지키려는 것뿐인데 라벨에도 써서
|
|
28
|
+
항목이 아직 없을 때(max=0) "0 / 1" 로 읽혔다 */
|
|
29
|
+
const shownMax = Number.isFinite(max) && max > 0 ? max : 0;
|
|
27
30
|
// 자리를 지정하지 않으면 굵기가 정한다 — 얇은 막대 안에는 글자가 들어가지 않는다.
|
|
28
31
|
// center 는 inside 의 다른 이름이라 여기서 하나로 모은다(클래스가 갈리지 않게).
|
|
29
32
|
const rawPos = labelPos ?? (size >= INSIDE_LABEL_MIN_SIZE ? 'inside' : 'bottom');
|
|
@@ -43,9 +46,9 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
|
|
|
43
46
|
rootStyle['--progress-bar-fill'] = color;
|
|
44
47
|
if (trackColor)
|
|
45
48
|
rootStyle['--progress-bar-track'] = trackColor;
|
|
46
|
-
return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax":
|
|
49
|
+
return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": shownMax, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
|
|
47
50
|
? formatValue(clamped)
|
|
48
51
|
: label === 'percent' ? `${Math.round(pct)}%`
|
|
49
52
|
: label === 'raw' ? String(clamped)
|
|
50
|
-
: `${clamped} / ${
|
|
53
|
+
: `${clamped} / ${shownMax}` }))] }));
|
|
51
54
|
}
|
|
@@ -2,6 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import useMediaQuery from '../hooks/useMediaQuery';
|
|
4
4
|
import './ProgressIndicator.css';
|
|
5
|
+
/** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
|
|
6
|
+
const 보이지않게 = {
|
|
7
|
+
position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
|
|
8
|
+
clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
|
|
9
|
+
};
|
|
5
10
|
/**
|
|
6
11
|
* transparent 일 때 남은 단계의 배경.
|
|
7
12
|
*
|
|
@@ -42,7 +47,7 @@ export default function ProgressIndicator({ max, index, inactiveBg, inactiveColo
|
|
|
42
47
|
묶고 이름표에 자리를 적는다. 화면이 스스로 넘어가는 자리에서는
|
|
43
48
|
announce 로 role="status" 를 켠다. */
|
|
44
49
|
if (variant === 'dot') {
|
|
45
|
-
return (
|
|
50
|
+
return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: 보이지않게, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
|
|
46
51
|
}
|
|
47
52
|
// 모바일: 현재 단계만 표시 (n / max 형태)
|
|
48
53
|
if (isMobile) {
|
|
@@ -79,7 +79,9 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
|
|
|
79
79
|
const star = i + 1;
|
|
80
80
|
const fillRatio = Math.max(0, Math.min(1, displayValue - i));
|
|
81
81
|
const isHalf = fillRatio > 0 && fillRatio < 1;
|
|
82
|
-
const starNode = (_jsxs("span", { className: "rating__star-wrap", children: [_jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--empty" }), fillRatio > 0 && (_jsx("span", { className: `rating__star-fill${isHalf ? ' rating__star-fill--half' : ''}`,
|
|
82
|
+
const starNode = (_jsxs("span", { className: "rating__star-wrap", children: [_jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--empty" }), fillRatio > 0 && (_jsx("span", { className: `rating__star-fill${isHalf ? ' rating__star-fill--half' : ''}`,
|
|
83
|
+
// 조각 별은 **값만큼** 채운다 — 반 별 클래스(50%)만 두어 4.1 과 4.9 가 같은 반 별로 그려졌다(이름은 "4.1")
|
|
84
|
+
style: isHalf ? { width: `${fillRatio * 100}%` } : undefined, children: _jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--filled" }) }))] }));
|
|
83
85
|
if (isReadOnly) {
|
|
84
86
|
return (_jsx("span", { className: "rating__item", "aria-hidden": "true", children: starNode }, star));
|
|
85
87
|
}
|
|
@@ -57,7 +57,10 @@ function SideNavButton(props) {
|
|
|
57
57
|
export default function SideNav(props) {
|
|
58
58
|
const { t } = useTranslation();
|
|
59
59
|
const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange } = props || {};
|
|
60
|
-
|
|
60
|
+
/* 모드를 안 주면 말귀로 본다 — Header(브랜드)·BottomNav 와 같게. 전에는 여기만 매니저로 봐 같은 화면에서
|
|
61
|
+
데스크톱은 '말귀로 이동', 모바일은 '말귀 매니저' 로 서로 반대를 가리켰다. */
|
|
62
|
+
const isManager = appMode === 'manager';
|
|
63
|
+
const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
|
|
61
64
|
return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
|
|
62
65
|
.filter((it) => !it.section && (it.key || it.label))
|
|
63
66
|
.map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
|
|
@@ -113,7 +116,7 @@ export default function SideNav(props) {
|
|
|
113
116
|
}
|
|
114
117
|
} })] }, it.key ?? `side-nav__item-${idx}`))), onAppModeChange && _jsx("div", { className: "side-nav__separator side-nav__separator--short" }), onAppModeChange && (_jsx(SideNavButton, { item: {
|
|
115
118
|
key: '__mode-switch__',
|
|
116
|
-
label:
|
|
117
|
-
icon:
|
|
119
|
+
label: isManager ? t('nav.goToMalgwi', '말귀로 이동') : t('nav.goToManager', '말귀매니저로 이동'),
|
|
120
|
+
icon: isManager ? 'arrow-left' : 'arrow-right',
|
|
118
121
|
}, onClick: handleModeSwitch }))] }), _jsx(SideNavFooter, { activeKey: activeKey, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp })] }));
|
|
119
122
|
}
|
|
@@ -33,8 +33,7 @@ export default function SplashLogo({ logo, alt, onDone, durationMs = 3000, }) {
|
|
|
33
33
|
// 담는다. 타이머는 한 번만 걸고, 울릴 때 **그때의 최신 콜백**을 부른다.
|
|
34
34
|
const onDoneRef = useLatestRef(onDone);
|
|
35
35
|
useEffect(() => {
|
|
36
|
-
|
|
37
|
-
return;
|
|
36
|
+
// onDone 이 없어도 타이머는 건다 — 처음에 없다가 나중에 들어오면(인증 확인 뒤 콜백을 넘기는 등) 전에는 끝내 안 걸려 스플래시가 남았다
|
|
38
37
|
const timer = setTimeout(() => onDoneRef.current?.(), durationMs);
|
|
39
38
|
return () => clearTimeout(timer);
|
|
40
39
|
}, [durationMs, onDoneRef]);
|
|
@@ -30,7 +30,7 @@ export default function StatChart({ label, value, change, changeType, icon, tool
|
|
|
30
30
|
}, [onClick]);
|
|
31
31
|
return (_jsxs("div", { ref: cardRef, className: `manager-stat-card${onClick ? ' manager-stat-card--clickable' : ''}`, onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, onPointerEnter: handleAnchorEnter, onPointerLeave: handleAnchorLeave,
|
|
32
32
|
// hover 전용이면 키보드로는 툴팁 내용을 볼 수 없다(ManagerPage와 동일 처리)
|
|
33
|
-
onFocus: handleAnchorEnter, onBlur: handleAnchorLeave, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ?
|
|
33
|
+
onFocus: handleAnchorEnter, onBlur: handleAnchorLeave, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? [label, value, changeText].filter((v) => v !== undefined && v !== '').join(' ') : undefined, children: [tooltip && anchorRect && (_jsx(TooltipInfo, { anchorRect: {
|
|
34
34
|
left: anchorRect.left,
|
|
35
35
|
top: anchorRect.top + 21,
|
|
36
36
|
right: anchorRect.right,
|
|
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import Tooltip from './Tooltip';
|
|
6
6
|
import './TrainingTooltip.css';
|
|
7
|
-
import {
|
|
7
|
+
import { translateTrainingTypes } from '../../utils/CategoryData';
|
|
8
8
|
import { getTrainingPools } from '../../utils/getTrainingData';
|
|
9
9
|
import TrainingBlockButton from '../Training/TrainingBlockButton';
|
|
10
10
|
/** 타입 칩 호버 → TrainingTooltip 표시까지의 지연(ms). 말귀자료실 카드와
|
|
@@ -20,10 +20,11 @@ function findTrainingData(sel) {
|
|
|
20
20
|
/** @public */
|
|
21
21
|
export default function TrainingTooltip({ plan, anchorRect, offset = 12, onClose }) {
|
|
22
22
|
const { t } = useTranslation();
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
// 머리글은 지금 언어로 — 원본 목록의 한국어 라벨을 써 영어 화면에도 '짧은 이야기 듣기' 가 떴다
|
|
24
|
+
const groups = useMemo(() => translateTrainingTypes(t).map(type => ({
|
|
25
|
+
type,
|
|
26
|
+
items: (plan.items ?? []).filter(i => i.trainingType === type.key),
|
|
27
|
+
})).filter(g => g.items.length > 0), [plan.items, t]);
|
|
27
28
|
if (groups.length === 0)
|
|
28
29
|
return null;
|
|
29
30
|
const box = (_jsx("div", { className: "tooltip-training__box", children: _jsx("div", { className: "tooltip-training__groups", children: groups.flatMap(({ type, items }, idx) => [
|
|
@@ -278,6 +278,63 @@
|
|
|
278
278
|
justify-content: center;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
/* ── row 변형 — 전체 너비 가로 행 ─────────────────────────────────────────────────
|
|
282
|
+
정사각형 카드는 폭이 줄면 내용이 눌린다. 좁은 화면 목록에서 한 줄에 한 장씩
|
|
283
|
+
[아이콘][유형/제목][문항수] 를 모두 보이게 grid 로 다시 놓는다. 미리 듣기 단추는 그리지 않는다. */
|
|
284
|
+
.training-block-button--row {
|
|
285
|
+
width: 100%;
|
|
286
|
+
height: auto;
|
|
287
|
+
min-height: 68px;
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
}
|
|
291
|
+
.training-block-button--row .training-block-button__left {
|
|
292
|
+
display: grid;
|
|
293
|
+
grid-template-columns: auto 1fr auto;
|
|
294
|
+
grid-template-areas:
|
|
295
|
+
"icon type count"
|
|
296
|
+
"icon title count";
|
|
297
|
+
align-items: center;
|
|
298
|
+
align-content: center;
|
|
299
|
+
height: auto;
|
|
300
|
+
column-gap: var(--spacing-s);
|
|
301
|
+
row-gap: 4px;
|
|
302
|
+
padding: 8px 0;
|
|
303
|
+
}
|
|
304
|
+
.training-block-button--row .category-icon__wrap {
|
|
305
|
+
grid-area: icon;
|
|
306
|
+
align-self: center;
|
|
307
|
+
}
|
|
308
|
+
.training-block-button--row .training-block-button__type-label {
|
|
309
|
+
grid-area: type;
|
|
310
|
+
align-self: end;
|
|
311
|
+
position: static;
|
|
312
|
+
inset: auto;
|
|
313
|
+
}
|
|
314
|
+
.training-block-button--row .training-block-button__title {
|
|
315
|
+
grid-area: title;
|
|
316
|
+
align-self: start;
|
|
317
|
+
position: static;
|
|
318
|
+
inset: auto;
|
|
319
|
+
white-space: nowrap;
|
|
320
|
+
overflow: hidden;
|
|
321
|
+
text-overflow: ellipsis;
|
|
322
|
+
}
|
|
323
|
+
.training-block-button--row .training-block-button__description {
|
|
324
|
+
grid-area: count;
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
gap: 4px;
|
|
328
|
+
position: static;
|
|
329
|
+
inset: auto;
|
|
330
|
+
height: auto;
|
|
331
|
+
}
|
|
332
|
+
/* 유형 줄이 없으면 제목이 두 줄 자리 가운데에 선다 */
|
|
333
|
+
.training-block-button--row:not(.training-block-button--withType) .training-block-button__title {
|
|
334
|
+
grid-row: 1 / span 2;
|
|
335
|
+
align-self: center;
|
|
336
|
+
}
|
|
337
|
+
|
|
281
338
|
/* preview play button: small 카드 우측 상단 — 좌측 아이콘(top:12)과 수평 정렬 */
|
|
282
339
|
.training-block-button--small .training-block-button__preview {
|
|
283
340
|
position: absolute;
|
|
@@ -167,7 +167,9 @@ export default function TrainingBlockButton(props) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
catch (_) { }
|
|
170
|
-
const
|
|
170
|
+
const isRow = size === 'row';
|
|
171
|
+
// row 는 list 의 바탕 위에 배치만 바꾼다
|
|
172
|
+
const isList = size === 'list' || isRow;
|
|
171
173
|
const isCompact = size === 'compact';
|
|
172
174
|
const isSmall = size === 'small';
|
|
173
175
|
const trainingTypeName = isSmall && type?.key
|
|
@@ -176,11 +178,11 @@ export default function TrainingBlockButton(props) {
|
|
|
176
178
|
// 타입 라벨이 실제로 렌더되는지(타이틀과 같으면 숨김) — 레이아웃 여백 정리에 사용.
|
|
177
179
|
const typeLabelRendered = (!!showTypeLabel && !!type?.label && type.label !== String(itemTitle)) ||
|
|
178
180
|
(isSmall && !!trainingTypeName && trainingTypeName !== String(itemTitle));
|
|
179
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: btnRef, className: `training-block-button${isList ? ' training-block-button--list' : ''}${isCompact ? ' training-block-button--compact' : ''}${isSmall ? ' training-block-button--small' : ''}${isCompleted ? ' training-block-button--completed' : ''}${isSelected ? ' training-block-button--selected' : ''}${typeLabelRendered && !isSmall ? ' training-block-button--withType' : ''}${isSmall && !typeLabelRendered ? ' training-block-button--no-type-label' : ''}`, onClick: handleActivate, onPointerEnter: handleBtnHoverOn, onPointerLeave: handleBtnHoverClose, role: "button", tabIndex: 0, onKeyDown: handleKeyDown, "aria-label": typeof ariaLabel === 'string' ? ariaLabel : (typeof itemTitle === 'string' ? itemTitle : undefined), children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && btnHover && (_jsx(Badge, { type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [(() => {
|
|
181
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: btnRef, className: `training-block-button${isList ? ' training-block-button--list' : ''}${isRow ? ' training-block-button--row' : ''}${isCompact ? ' training-block-button--compact' : ''}${isSmall ? ' training-block-button--small' : ''}${isCompleted ? ' training-block-button--completed' : ''}${isSelected ? ' training-block-button--selected' : ''}${typeLabelRendered && !isSmall ? ' training-block-button--withType' : ''}${isSmall && !typeLabelRendered ? ' training-block-button--no-type-label' : ''}`, onClick: handleActivate, onPointerEnter: handleBtnHoverOn, onPointerLeave: handleBtnHoverClose, role: "button", tabIndex: 0, onKeyDown: handleKeyDown, "aria-label": typeof ariaLabel === 'string' ? ariaLabel : (typeof itemTitle === 'string' ? itemTitle : undefined), children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && btnHover && (_jsx(Badge, { type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [(() => {
|
|
180
182
|
const countText = countLabelOverride !== undefined ? countLabelOverride : countLabel;
|
|
181
183
|
// 정해진 문항수가 없으면(빈 값) 카운트 요소 자체를 그리지 않는다.
|
|
182
184
|
return countText ? (_jsx("div", { className: `training-block-button__count ${isSmall ? 'caption-2' : 'caption-1'}`, children: countText })) : null;
|
|
183
|
-
})(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), (isList || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
|
|
185
|
+
})(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), ((isList && !isRow) || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
|
|
184
186
|
trainingData: topic,
|
|
185
187
|
typeKey: type?.key ?? '',
|
|
186
188
|
onClose: handleClose,
|