@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +181 -181
  7. package/dist/ihabdevteam-core.d.ts +19 -3
  8. package/dist/packages/core/src/components/Alert.js +1 -1
  9. package/dist/packages/core/src/components/Button.css +5 -0
  10. package/dist/packages/core/src/components/Button.js +3 -3
  11. package/dist/packages/core/src/components/Checkbox.js +10 -2
  12. package/dist/packages/core/src/components/CodeBox.js +2 -1
  13. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +2 -0
  14. package/dist/packages/core/src/components/IconGridMenu.js +25 -22
  15. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -1
  16. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
  17. package/dist/packages/core/src/components/Popup/Popup.css +9 -0
  18. package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
  19. package/dist/packages/core/src/components/Popup/Popup.js +2 -2
  20. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
  21. package/dist/packages/core/src/components/ProgressBar.js +5 -2
  22. package/dist/packages/core/src/components/ProgressIndicator.js +6 -1
  23. package/dist/packages/core/src/components/Rating.js +3 -1
  24. package/dist/packages/core/src/components/SideNav.js +6 -3
  25. package/dist/packages/core/src/components/SplashLogo.js +1 -2
  26. package/dist/packages/core/src/components/StatChart.js +1 -1
  27. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
  28. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
  29. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +5 -3
  30. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
  31. package/dist/packages/core/src/components/Training/TrainingStepCard.js +4 -3
  32. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
  33. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
  34. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
  35. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
  36. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
  37. package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
  38. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  39. package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
  40. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
  41. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
  42. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
  43. package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
  44. package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
  45. package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
  46. package/dist/packages/core/src/context/I18nProvider.js +12 -3
  47. package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
  48. package/dist/packages/core/src/i18n/locales/en.json +1 -0
  49. package/dist/packages/core/src/i18n/locales/ko.json +1 -0
  50. package/dist/packages/core/src/utils/date.js +26 -5
  51. package/dist/packages/core/src/utils/speak.js +23 -3
  52. package/dist/packages/core/src/utils/textSort.js +12 -1
  53. package/dist/packages/core/src/utils/ttsConfig.js +10 -8
  54. package/dist/packages/core/src/version.d.ts +1 -1
  55. package/dist/packages/core/src/version.js +1 -1
  56. package/dist/utils.cjs.js +2 -2
  57. 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.135.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 ?? 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" }))] }));
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
  }
@@ -432,6 +432,11 @@
432
432
  cursor: default;
433
433
  }
434
434
 
435
+ /* 툴팁만 보여 주는 칩 — 누를 수 있다고 말하지 않는다 */
436
+ .button.button--tooltip-only {
437
+ cursor: default;
438
+ }
439
+
435
440
 
436
441
 
437
442
 
@@ -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": isLink && disabledProp ? true : undefined, style: style, onClick: handleClickInternal, onMouseLeave: handleMouseLeave, onPointerEnter: (e) => {
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
- }, [indeterminate]);
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 => onChange?.(e.target.checked), disabled: disabled, "aria-label": !label && ariaLabel ? ariaLabel : undefined }), _jsx("span", { className: "checkbox__box" }), label && _jsx("span", { className: "checkbox__label body-3", children: label })] }));
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
- const isBash = /sh|bash/.test(lang.toLowerCase());
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
- const initial = selected !== undefined ? selected : ((categories && categories[0]?.key) ?? null);
21
- const [selectedKey, setSelectedKey] = useState(initial ?? null);
22
- // 외부 selected prop이 controlled로 전달되면 내부 상태 동기화하는 의도된 동기화다
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
- /* null 도 따른다 — 예전에는 null 을 걸러 버려, 부모가 selected={null} 로 선택을 풀어도
26
- 단추는 골라진 채로 남았다. */
27
- if (selected !== undefined) {
28
- setSelectedKey(selected);
29
- }
30
- }, [selected]);
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
- setSelectedKey(next);
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: isSelected ? 'popup-list__row--selected' : col.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, "\uAC1C"] })] })] }, 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
+ 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))) })] }), _jsxs("span", { className: "load-table-popup__row-count caption-1", children: [table.items.length, "\uAC1C"] }), _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
+ : 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
- if (!sec || sec <= 0)
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(sec / 60);
83
- const s = sec % 60;
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": safeMax, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
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} / ${safeMax}` }))] }));
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 (_jsx("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": groupLabel, children: Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i))) }));
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' : ''}`, children: _jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--filled" }) }))] }));
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
- const handleModeSwitch = useCallback(() => onAppModeChange?.(appMode === 'malgwi' ? 'manager' : 'malgwi'), [onAppModeChange, appMode]);
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: appMode === 'malgwi' ? t('nav.goToManager', '말귀매니저로 이동') : t('nav.goToMalgwi', '말귀로 이동'),
117
- icon: appMode === 'malgwi' ? 'arrow-right' : 'arrow-left',
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
- if (!onDoneRef.current)
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 ? `${label} ${value}` : undefined, children: [tooltip && anchorRect && (_jsx(TooltipInfo, { anchorRect: {
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 { TRAINING_TYPES } from '../../utils/CategoryData';
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
- const groups = useMemo(() => TRAINING_TYPES.map(t => ({
24
- type: t,
25
- items: (plan.items ?? []).filter(i => i.trainingType === t.key),
26
- })).filter(g => g.items.length > 0), [plan.items]);
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 isList = size === 'list';
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,