@ihabdevteam/core 0.153.0 → 0.155.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 (29) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/components.cjs.js +178 -173
  3. package/dist/components.esm.css +3 -3
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +186 -181
  7. package/dist/ihabdevteam-core.d.ts +43 -4
  8. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  9. package/dist/packages/core/src/components/BarChart.js +4 -3
  10. package/dist/packages/core/src/components/Table.js +41 -15
  11. package/dist/packages/core/src/components/TableField.css +32 -0
  12. package/dist/packages/core/src/components/WordTable.css +48 -1
  13. package/dist/packages/core/src/components/WordTable.d.ts +7 -1
  14. package/dist/packages/core/src/components/WordTable.js +168 -10
  15. package/dist/packages/core/src/components/barChartTypes.d.ts +6 -0
  16. package/dist/packages/core/src/hooks/useTrainingSessionLog.d.ts +16 -2
  17. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +34 -11
  18. package/dist/packages/core/src/i18n/locales/en.json +5 -0
  19. package/dist/packages/core/src/i18n/locales/ko.json +5 -0
  20. package/dist/packages/core/src/utils/lucideIconSet.js +2 -0
  21. package/dist/packages/core/src/utils/planFeatures.d.ts +28 -1
  22. package/dist/packages/core/src/utils/planFeatures.js +38 -8
  23. package/dist/packages/core/src/utils/wordTrainingPool.js +31 -3
  24. package/dist/packages/core/src/utils.d.ts +1 -1
  25. package/dist/packages/core/src/utils.js +1 -1
  26. package/dist/packages/core/src/version.d.ts +1 -1
  27. package/dist/packages/core/src/version.js +1 -1
  28. package/dist/utils.cjs.js +2 -2
  29. package/package.json +1 -1
@@ -309,7 +309,7 @@ export declare type BadgeType = 'bar' | 'dot';
309
309
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
310
310
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
311
311
  */
312
- export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, inGrid, }: BarChartProps): JSX.Element;
312
+ export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, headExtra, className, inGrid, }: BarChartProps): JSX.Element;
313
313
 
314
314
  /** @public */
315
315
  export declare interface BarChartControl {
@@ -401,6 +401,11 @@ export declare interface BarChartProps {
401
401
  loading?: boolean;
402
402
  /** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
403
403
  onDetail?: () => void;
404
+ /** 머리 오른쪽 묶음에서 기간 컨트롤과 '상세보기' **사이** 에 놓을 것 — 앱이 카드 머리에 얹는 작은 조작(예: 시간/정답률 SegmentedControl size 32).
405
+ * size='small' 에서도 이것만은 그린다(기간 컨트롤은 그대로 숨김). */
406
+ headExtra?: ReactNode;
407
+ /** 카드(section)에 얹을 클래스 — 격자에서 칸 수를 잡을 때(`grid-column`) 감싸개 div 없이. RingChart 와 같은 뜻. */
408
+ className?: string;
404
409
  /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
405
410
  inGrid?: boolean;
406
411
  }
@@ -1460,7 +1465,7 @@ export declare function copyText(text: string): Promise<boolean>;
1460
1465
  *
1461
1466
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1462
1467
  */
1463
- export declare const CORE_VERSION = "0.153.0";
1468
+ export declare const CORE_VERSION = "0.155.0";
1464
1469
 
1465
1470
  /** @public */
1466
1471
  declare const coreDefault: Record<string, unknown>;
@@ -2715,6 +2720,14 @@ export declare function injectPromoBannerBgTheme(el: HTMLElement | null, theme:
2715
2720
  /** @public 검색어가 초성으로만 이루어졌는지 (예: 'ㅎㅈ' → true, '한' → false) */
2716
2721
  export declare function isAllChoseong(search: string): boolean;
2717
2722
 
2723
+ /**
2724
+ * @public 티어가 매니저 줄(말귀매니저)인지. `none` 과 개인 줄(free·personal_standard)은 false.
2725
+ *
2726
+ * 요금제 비교표처럼 "이 칸은 매니저 플랜에만 있다" 를 그리는 곳이 티어 목록을 손으로
2727
+ * 들고 있지 않도록 여기서 내준다 — 티어가 늘면 그 목록만 뒤처진다.
2728
+ */
2729
+ export declare function isManagerTier(tier: PlanTier): boolean;
2730
+
2718
2731
  /** @public 자동 문제생성 불가(수동 구성 전용) 타입인가. */
2719
2732
  export declare function isManualOnlyType(typeKey: WordTrainingTypeKey): boolean;
2720
2733
 
@@ -4155,6 +4168,15 @@ export declare interface PlanFeatures {
4155
4168
  maxCustomers: number;
4156
4169
  visitWord: boolean;
4157
4170
  visitSentence: boolean;
4171
+ /**
4172
+ * 숙제내주기(쿠폰) — **노출 여부만** 말한다.
4173
+ *
4174
+ * 하루 몇 번 보낼 수 있는지는 `PLAN_LIMITS[tier].homeworkSendDaily` 가 정본이고,
4175
+ * 어드민 요금제 비교표의 '숙제 보내기' 칸도 그 숫자로 그린다. 이 플래그는 그 숫자가
4176
+ * 있는 티어에서 켜져 있어야 한다 — 전에는 mgr_free·lite·standard 가 하루 1·10·30회를
4177
+ * 받으면서도 false 라, 같은 화면이 한 칸에서는 횟수를 말하고 옆 칸에서는 ✗ 를 말했다
4178
+ * (사용자 결정 2026-09-16). 숫자를 고칠 때는 이 플래그도 같이 본다.
4179
+ */
4158
4180
  homework: boolean;
4159
4181
  customerStats: boolean;
4160
4182
  managerCustomerSplit: boolean;
@@ -6713,7 +6735,18 @@ export declare interface TextFieldProps {
6713
6735
  */
6714
6736
  export declare type ThemeMode = 'light' | 'dark' | 'auto';
6715
6737
 
6716
- /** 요금제 티어 순위 (높을수록 상위 요금제) */
6738
+ /**
6739
+ * 요금제 티어 순위 — **같은 줄 안에서 견주는 값**이다(클수록 상위 요금제).
6740
+ *
6741
+ * 줄마다 자리를 갈라 둔다: 개인 줄 0~9, 매니저 줄 10~19. 전에는 한 자리에 섞여 있어
6742
+ * (personal_standard 3 < mgr_free 4) 줄이 다른 두 티어의 순위 차가 무슨 뜻인지 읽을 수
6743
+ * 없었다(사용자 결정 2026-09-16). 줄이 같은지는 `isManagerTier` 로 본다.
6744
+ *
6745
+ * 줄 사이의 크기 비교(매니저 줄이 개인 줄보다 큼)는 전과 같은 방향이라, 활성 구독 중
6746
+ * 가장 높은 티어를 고르는 코드는 그대로 동작한다 — 다만 그런 비교는 "매니저 줄이
6747
+ * 개인 줄보다 상위"라는 뜻이지 기능 수의 많고 적음이 아니다.
6748
+ * 레거시 라이트(낱말·문장)는 서로 대등해 같은 자리를 쓴다.
6749
+ */
6717
6750
  export declare const TIER_RANK: Record<PlanTier, number>;
6718
6751
 
6719
6752
  /**
@@ -9506,7 +9539,13 @@ export declare interface WordTableProps {
9506
9539
  onRemoveCell?: (index: number) => void;
9507
9540
  /** 표시 항목 제거 */
9508
9541
  onRemoveItem?: (id: string) => void;
9509
- /** 표시 항목 드래그 재정렬 */
9542
+ /**
9543
+ * 표시 항목 순서 바꾸기. 칸을 옮길 수 있게 하고 `(fromIndex, toIndex)` 를 알린다.
9544
+ *
9545
+ * 옮기는 길이 셋이다 — **마우스로 끌기**(HTML5 드래그 앤 드롭), 칸 안 **옮기기 손잡이**에
9546
+ * 초점을 두고 `Alt`+`←`/`→`, 그리고 **길게 눌러**(400ms) 손가락으로 끌기.
9547
+ * 키보드로 옮기면 초점이 그 칸을 따라가고 바뀐 자리를 `role="status"` 로 알린다.
9548
+ */
9510
9549
  onReorder?: (fromIndex: number, toIndex: number) => void;
9511
9550
  /** 표시 모드에서 `count` 안의 빈칸(항목 수 뒤 ~ count)에 '추가' 버튼 노출. count 가 항목 수와 같으면 그런 칸이 없다. `onActivateCell` 필요. */
9512
9551
  showFillerAdd?: boolean;
@@ -13,4 +13,4 @@ import type { BarChartProps } from './barChartTypes';
13
13
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
14
14
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
15
15
  */
16
- export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, inGrid, }: BarChartProps): import("react").JSX.Element;
16
+ export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, headExtra, className, inGrid, }: BarChartProps): import("react").JSX.Element;
@@ -202,7 +202,7 @@ const BAR_SIZE_PRESET = {
202
202
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
203
203
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
204
204
  */
205
- export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, tightMax = false, avgMode = false, totalFormat, series, loading = false, onDetail, inGrid = false, }) {
205
+ export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, tightMax = false, avgMode = false, totalFormat, series, loading = false, onDetail, headExtra, className, inGrid = false, }) {
206
206
  const { t, i18n } = useTranslation();
207
207
  const sizePreset = BAR_SIZE_PRESET[size];
208
208
  // 외부 control 미지정 시 내부 컨트롤 사용 (단독 차트 하위 호환)
@@ -266,8 +266,9 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
266
266
  const isSmall = size === 'small';
267
267
  // size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
268
268
  const isLarge = size === 'large';
269
- const sectionClassName = isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined;
270
- return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? undefined : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
269
+ const sectionClassName = [isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined, className]
270
+ .filter(Boolean).join(' ') || undefined;
271
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
271
272
  // 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
272
273
  _jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
273
274
  // 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
@@ -47,20 +47,31 @@ function SortArrow({ dir }) {
47
47
  const Icon = dir === 'asc' ? ChevronUp : ChevronDown;
48
48
  return _jsx(Icon, { className: "table-field__sort-arrow", size: 16, strokeWidth: 2 });
49
49
  }
50
- function TableField({ children, onClick, className, sortDir, onResizeStart }) {
51
- // 정렬 가능한 컬럼 헤더는 클릭만 받고 있어 키보드로는 정렬할 수 없었다.
52
- // TableRow와 같은 방식으로 Enter/Space를 처리한다(리사이저 등 자식에서 온 키는 무시).
53
- const handleKeyDown = useCallback((e) => {
54
- if (!onClick)
55
- return;
56
- if (e.target !== e.currentTarget)
57
- return;
58
- if (e.key === 'Enter' || e.key === ' ') {
59
- e.preventDefault();
60
- onClick();
61
- }
62
- }, [onClick]);
63
- return (_jsxs("div", { onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-sort": sortDir ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined, className: cx('table-field body-3', className, sortDir ? 'table-field--sorted' : '', onResizeStart ? 'table-field--resizable' : ''), children: [children, sortDir && _jsx(SortArrow, { dir: sortDir }), onResizeStart && (_jsx("span", { className: "table-field__resizer", onPointerDown: onResizeStart, onClick: e => e.stopPropagation(), "aria-hidden": "true" }))] }));
50
+ /**
51
+ * 머리 한 칸.
52
+ *
53
+ * **`aria-sort` 는 머리 칸에 붙는다 — 누르는 것에 붙이면 무시된다.**
54
+ * 예전에는 이 `div` 자체가 `role="button"` 이었고 그 위에 `aria-sort` 를 얹었다.
55
+ * `aria-sort` 는 `columnheader`·`rowheader` 에만 뜻이 있는 속성이라 `button` 에
56
+ * 붙은 것은 통째로 버려진다 — 낭독기는 "이름, 버튼" 까지만 읽고 어느 칸으로 어느
57
+ * 방향으로 정렬됐는지는 한 마디도 안 했다. 눈에는 화살표가 보여 오래 안 드러났다.
58
+ *
59
+ * 그래서 **칸과 누르는 것을 가른다.** 칸은 `columnheader` 로 남아 `aria-sort` 를 들고,
60
+ * 누르는 것은 그 안의 **진짜 `<button>`** 이다. 진짜 단추라 Enter·Space 는 브라우저가
61
+ * 처리한다 — 손으로 붙이던 `onKeyDown` 이 필요 없다.
62
+ *
63
+ * `onClick` 은 단추가 아니라 **칸**에 그대로 둔다. 단추가 낸 클릭도 여기로 올라오므로
64
+ * 동작은 같고, 여백(padding)을 눌러도 정렬되던 것 · 칸을 집어 누르던 부르는 쪽 코드가
65
+ * 그대로 산다. 칸에는 `role`·`tabIndex` 를 주지 않으므로 탭 정지점은 단추 하나뿐이다.
66
+ */
67
+ function TableField({ children, onClick, className, sortDir, ariaSort, onResizeStart }) {
68
+ const 속 = _jsxs(_Fragment, { children: [children, sortDir && _jsx(SortArrow, { dir: sortDir })] });
69
+ return (_jsxs("div", { onClick: onClick, role: "columnheader", "aria-sort": ariaSort, className: cx('table-field body-3', className, sortDir ? 'table-field--sorted' : '', onResizeStart ? 'table-field--resizable' : ''), children: [onClick
70
+ /* 단추 안에 **또 다른 조작**을 넣지 않는다 — `label` 에 `role="button"` 인 조각이나
71
+ Checkbox 를 넣어 두면 그것이 이 단추 안으로 들어와 겹친 조작이 된다(HTML 위반).
72
+ 그런 머리는 `onClick` 없이 두고, 정렬은 그 조각이 알아서 맡는다. */
73
+ ? _jsx("button", { type: "button", className: "table-field__button", children: 속 })
74
+ : 속, onResizeStart && (_jsx("span", { className: "table-field__resizer", onPointerDown: onResizeStart, onClick: e => e.stopPropagation(), "aria-hidden": "true" }))] }));
64
75
  }
65
76
  /** @public */
66
77
  export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
@@ -298,6 +309,21 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
298
309
  '머리와 몸통은 서로 다른 격자라 그 트랙이 각자 다르게 풀려 열이 어긋납니다. px 이나 fr 을 쓰세요. ' +
299
310
  `(받은 값: ${gridTemplateColumns})`);
300
311
  }
312
+ /* **정렬 중인 칸은 하나뿐이다.** `aria-sort` 는 표 하나에 한 칸만 가질 수 있다(WAI-ARIA) —
313
+ 둘에 붙으면 낭독기가 어느 쪽이 지금 정렬인지 못 고른다. 여럿이 오면 **첫 칸만** 방향을 갖고
314
+ 나머지는 누를 수 있는 칸의 기본값(none)으로 떨어뜨린 뒤 개발 모드에서 알린다.
315
+ (실제 부르는 쪽 — `useTablePaging`·`ListPopup` — 은 이미 하나만 준다.) */
316
+ const 정렬칸 = fields.findIndex(f => !!f.sortDir);
317
+ if (fields.filter(f => !!f.sortDir).length > 1) {
318
+ devWarnOnce('table-multi-sort', '[ihabdevteam-core] Table 의 fields 에 sortDir 이 둘 이상 있습니다. ' +
319
+ 'aria-sort 는 표 하나에 한 칸만 가질 수 있어 첫 칸만 방향으로 알리고 나머지는 none 으로 둡니다.');
320
+ }
321
+ /** 머리 칸의 `aria-sort` — 정렬 중인 한 칸만 방향, 누를 수 있는 나머지는 none, 정렬과 무관한 칸에는 안 붙인다 */
322
+ const ariaSortOf = (i) => {
323
+ if (i === 정렬칸)
324
+ return fields[i].sortDir === 'asc' ? 'ascending' : 'descending';
325
+ return fields[i].onClick ? 'none' : undefined;
326
+ };
301
327
  const isFixedPx = (i) => /^\d+(\.\d+)?px$/.test(defaultCols[i] ?? '');
302
328
  /* `자동맞춤` 은 이 둘보다 위에서 정의되므로(콜백이라 부를 때 읽힌다) ref 로 건넨다 */
303
329
  isFixedPxRef.current = isFixedPx;
@@ -318,7 +344,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
318
344
  return; if (e.key === 'Enter' || e.key === ' ') {
319
345
  e.preventDefault();
320
346
  onToggleCollapse();
321
- } } : undefined, children: [onToggleCollapse && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir,
347
+ } } : undefined, children: [onToggleCollapse && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir, ariaSort: ariaSortOf(i),
322
348
  /* fit 모드는 오른쪽 이웃에서 폭을 덜어 오므로, 덜어 올 컬럼이 없는 자리에는
323
349
  손잡이를 두지 않는다 — 두면 그 컬럼만 grow처럼 넓어져 표가 밀린다. */
324
350
  onResizeStart: 폭조절 && i < fields.length - 1 && f.resizable !== false
@@ -17,6 +17,38 @@
17
17
  overflow: hidden;
18
18
  }
19
19
 
20
+ /* ── 정렬 머리의 단추 ──────────────────────────────────────
21
+ 머리 칸(.table-field)은 columnheader 로 남아 aria-sort 를 들고, **누르는 것은
22
+ 그 안의 진짜 <button>** 이다(Table.tsx 의 TableField 주석 참고).
23
+
24
+ **여백(padding)은 칸에 그대로 둔다.** 단추로 옮기면 소비 앱이 `.table-field`
25
+ padding 을 덮어쓴 자리(말귀 어드민 DataPage 는 `0 12px`)에서 둘이 더해져
26
+ 28px 이 된다. 칸 여백은 그대로고 단추는 내용 상자를 꽉 채운다 — 여백을 눌러도
27
+ 칸의 onClick 으로 그대로 정렬된다(초점 테두리만 글자 쪽에 붙는다).
28
+
29
+ 높이도 물려받게 `align-self: stretch` — kscan 이 `.table-field { height: 38px }`
30
+ 로 줄여 둔 인쇄용 표에서 단추가 제 높이로 부풀지 않게 한다. */
31
+ .table-field__button {
32
+ flex: 1 1 auto;
33
+ min-width: 0;
34
+ align-self: stretch;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 0;
38
+ padding: 0;
39
+ margin: 0;
40
+ border: 0;
41
+ background: none;
42
+ /* 글꼴·색은 칸에서 물려받는다 — body-3 와 .table-field:hover 색이 그대로 산다 */
43
+ font: inherit;
44
+ color: inherit;
45
+ text-align: left;
46
+ cursor: pointer;
47
+ white-space: nowrap;
48
+ overflow: hidden;
49
+ user-select: none;
50
+ }
51
+
20
52
  /* ── 컬럼 폭 리사이즈 핸들 (헤더 경계선 드래그) ── */
21
53
  .table-field--resizable { position: relative; }
22
54
  /* 손잡이는 **열 안쪽**에 둔다. 예전엔 right:-5px 로 경계에 걸치게 뒀는데, 그러면
@@ -109,6 +109,37 @@
109
109
  z-index: 2;
110
110
  }
111
111
 
112
+ /* 손가락에 끌려가는 중인 칸(길게 누르기로 시작한 끌기).
113
+ touch-action: none 은 **끌기가 익은 뒤에만** 걸린다 — 늘 걸어 두면 표 위에서 화면이
114
+ 스크롤되지 않는다. 그래서 이 클래스에만 둔다(막는 몫의 대부분은 WordTable.tsx 가
115
+ passive:false 로 단 touchmove 가 하고, 이건 뒤늦게 오는 손짓을 막는 보탬이다). */
116
+ .word-table-block__card--dragging {
117
+ touch-action: none;
118
+ opacity: 0.6;
119
+ transform: scale(0.97);
120
+ box-shadow: var(--shadow-dropdown);
121
+ z-index: 3;
122
+ transition: transform 0.12s, opacity 0.12s;
123
+ }
124
+
125
+ /* 옮기기 손잡이 — 빼기 단추와 같은 규칙(평소엔 숨고 hover·초점에 드러난다), 자리만 왼쪽이다 */
126
+ .word-table-block__card-handle {
127
+ position: absolute;
128
+ top: 20px;
129
+ left: 20px;
130
+ z-index: 1;
131
+ opacity: 0;
132
+ transition: opacity 0.15s;
133
+ cursor: grab;
134
+ }
135
+
136
+ .word-table-block__card:focus-within .word-table-block__card-handle,
137
+ .word-table-block__card-handle:focus-within,
138
+ .word-table-block__card-handle:focus-visible,
139
+ .word-table-block__card:hover .word-table-block__card-handle {
140
+ opacity: 1;
141
+ }
142
+
112
143
  .word-table-block__card-close {
113
144
  position: absolute;
114
145
  top: 20px;
@@ -270,12 +301,27 @@
270
301
  (보이지 않는 단추를 짐작으로 눌러야 했다). 늘 보이게만 한다 — 색·모양은 그대로. 인쇄에서는 아래 @media print 가 치운다. */
271
302
  @media (hover: none) {
272
303
  .word-table-block__card-close,
304
+ .word-table-block__card-handle,
273
305
  .word-table-cell__play-button,
274
306
  .word-table-cell__info-button {
275
307
  opacity: 1;
276
308
  }
277
309
  }
278
310
 
311
+ /* 움직임을 줄여 달라고 한 사람에게는 끌기 중 움직임 효과를 뺀다 — 자리가 바뀌었다는 것은
312
+ 테두리(--drag-over)와 흐려짐만으로도 알 수 있다. 크기가 튀는 것이 어지럼을 부른다. */
313
+ @media (prefers-reduced-motion: reduce) {
314
+ .word-table-block__card,
315
+ .word-table-block__card-handle,
316
+ .word-table-block__card-close {
317
+ transition: none;
318
+ }
319
+ .word-table-block__card--dragging {
320
+ transform: none;
321
+ transition: none;
322
+ }
323
+ }
324
+
279
325
  .word-table-cell__image-wrap {
280
326
  position: relative;
281
327
  width: 240px;
@@ -345,7 +391,8 @@
345
391
  transform: none !important;
346
392
  }
347
393
 
348
- .word-table-block__card-close { display: none; }
394
+ .word-table-block__card-close,
395
+ .word-table-block__card-handle { display: none; }
349
396
 
350
397
  .word-table-cell__play-button,
351
398
  .word-table-cell__info-button,
@@ -28,7 +28,13 @@ export interface WordTableProps {
28
28
  onRemoveCell?: (index: number) => void;
29
29
  /** 표시 항목 제거 */
30
30
  onRemoveItem?: (id: string) => void;
31
- /** 표시 항목 드래그 재정렬 */
31
+ /**
32
+ * 표시 항목 순서 바꾸기. 칸을 옮길 수 있게 하고 `(fromIndex, toIndex)` 를 알린다.
33
+ *
34
+ * 옮기는 길이 셋이다 — **마우스로 끌기**(HTML5 드래그 앤 드롭), 칸 안 **옮기기 손잡이**에
35
+ * 초점을 두고 `Alt`+`←`/`→`, 그리고 **길게 눌러**(400ms) 손가락으로 끌기.
36
+ * 키보드로 옮기면 초점이 그 칸을 따라가고 바뀐 자리를 `role="status"` 로 알린다.
37
+ */
32
38
  onReorder?: (fromIndex: number, toIndex: number) => void;
33
39
  /** 표시 모드에서 `count` 안의 빈칸(항목 수 뒤 ~ count)에 '추가' 버튼 노출. count 가 항목 수와 같으면 그런 칸이 없다. `onActivateCell` 필요. */
34
40
  showFillerAdd?: boolean;
@@ -22,6 +22,20 @@ import { claimSound } from './Training/currentSound';
22
22
  통째로 날아간다. 쓰는 쪽은 `wordTableLayout` 에서 바로 가져온다.
23
23
  (상수 하나는 남긴다 — 규칙이 상수는 허용하고, 지우면 공개 API 가 바뀐다.) */
24
24
  export { WORD_TABLE_PAPER } from './wordTableLayout';
25
+ /** 눈에는 안 보이고 낭독기에는 읽히는 글자(ProgressIndicator 와 같은 규칙) */
26
+ const 보이지않게 = {
27
+ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
28
+ clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
29
+ };
30
+ /**
31
+ * **길게 누르면 끌기가 시작된다** — 터치로 순서를 바꾸는 길.
32
+ *
33
+ * 300~500ms 가 흔한 값이다(iOS 의 길게 누르기는 500ms, Android 는 400ms 안팎).
34
+ * 짧으면 살짝 스친 손가락에 칸이 끌려가고, 길면 "안 되네" 하고 손을 뗀다.
35
+ */
36
+ const 길게누름_MS = 400;
37
+ /** 길게 누르기가 익기 전에 이만큼 움직이면 그건 끌기가 아니라 **스크롤**이라 취소한다 */
38
+ const 움직임문턱_PX = 8;
25
39
  function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true, showImage = true, showPronunciation = true, showColloquial = true, buttonSize = 40, wordInfo, }) {
26
40
  const { t } = useTranslation();
27
41
  const outerRef = useRef(null);
@@ -182,6 +196,140 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
182
196
  // 드래그 상태(표시 모드 재정렬)
183
197
  const dragIndex = useRef(null);
184
198
  const [dragOver, setDragOver] = useState(null);
199
+ /* ── 칸 순서 바꾸기 — 마우스 밖의 두 길 ───────────────────────────────
200
+ `onReorder` 는 HTML5 드래그 앤 드롭뿐이었다. 키보드만 쓰는 사람에게는 순서를
201
+ 바꿀 길이 아예 없었고(끌 수 있다는 것조차 알 수 없다), 터치 기기에서는 HTML5 DnD 가
202
+ 굴러가지도 않아 손가락으로도 못 바꿨다. 표를 만드는 화면의 핵심 조작인데
203
+ 마우스 있는 사람만 쓸 수 있었다. 마우스 끌기는 그대로 두고 두 길을 더한다. */
204
+ const 낱말수 = items?.length ?? 0;
205
+ /** 지금 손가락에 끌려가는 칸 */
206
+ const [끌기, set끌기] = useState(null);
207
+ /** 낭독기에 바뀐 자리를 알리는 글. 처음에는 비워 둔다 — 열자마자 뜬금없이 읽지 않게. */
208
+ const [옮김알림, set옮김알림] = useState('');
209
+ /** 다시 그린 뒤 초점을 둘 칸 번호 — 옮긴 칸을 초점이 따라가야 다음 Alt+화살표가 같은 칸을 민다 */
210
+ const 초점갈곳 = useRef(null);
211
+ /** 칸 격자 DOM — 손가락 밑의 칸을 찾고, 옮긴 뒤 손잡이에 초점을 주는 데 쓴다 */
212
+ const 격자 = useRef(null);
213
+ /** 지금 떨어뜨릴 자리(상태와 같은 값의 ref) — pointerup 은 마지막 렌더를 기다려 주지 않는다 */
214
+ const 떨굴자리 = useRef(null);
215
+ /** 길게 누르기가 걸어 둔 것들을 한 번에 푸는 함수 */
216
+ const 풀기 = useRef(null);
217
+ /** `contentRef`(인쇄·캡처용, 부르는 쪽 것)와 우리 `격자` 를 함께 채운다 */
218
+ const 내용ref = useCallback((el) => {
219
+ 격자.current = el;
220
+ if (typeof contentRef === 'function')
221
+ contentRef(el);
222
+ else if (contentRef)
223
+ contentRef.current = el;
224
+ }, [contentRef]);
225
+ const 옮기기 = useCallback((from, to) => {
226
+ 초점갈곳.current = to;
227
+ onReorder?.(from, to);
228
+ set옮김알림(t('wordTable.reorder.moved', {
229
+ n: to + 1, total: 낱말수, defaultValue: '{{total}}칸 가운데 {{n}}번째로 옮김',
230
+ }));
231
+ }, [onReorder, t, 낱말수]);
232
+ /* 옮긴 칸을 초점이 따라간다. 자리를 잃으면 다음 Alt+화살표가 엉뚱한 칸을 민다 —
233
+ 아예 문서 처음으로 튀어 어디였는지도 알 수 없다. 의존성 배열 없이 매 렌더 뒤에
234
+ 보되 `초점갈곳` 이 비어 있으면 곧바로 돌아온다(거의 언제나 그렇다). */
235
+ useEffect(() => {
236
+ const to = 초점갈곳.current;
237
+ if (to == null)
238
+ return;
239
+ 초점갈곳.current = null;
240
+ const 칸 = 격자.current?.querySelectorAll('.word-table-block__card:not(.word-table-block__card--filler)');
241
+ 칸?.[to]?.querySelector('.word-table-block__card-handle button')?.focus();
242
+ });
243
+ /* 떠날 때 걸어 둔 것을 푼다 — 길게 누르는 중에 팝업이 닫히면 window 리스너와
244
+ 타이머가 남아 사라진 화면의 상태를 건드린다(표의 폭 조절에서 겪은 것과 같다). */
245
+ useEffect(() => () => { 풀기.current?.(); }, []);
246
+ /**
247
+ * **키보드로 옮긴다** — 손잡이에 초점을 두고 `Alt`+`←`/`→`.
248
+ *
249
+ * 낱말 카드 안 다른 단추(재생·정보·빼기)는 Enter·Space 로 눌리고, 화살표만으로는
250
+ * 아무 일도 안 일어난다. `Alt` 를 얹어 그 둘 어느 쪽과도 부딪히지 않게 했다.
251
+ */
252
+ const 키로옮기기 = useCallback((e, index) => {
253
+ if (!onReorder)
254
+ return;
255
+ if (!e.altKey || e.ctrlKey || e.metaKey)
256
+ return;
257
+ const 뒤로 = e.key === 'ArrowRight';
258
+ if (!뒤로 && e.key !== 'ArrowLeft')
259
+ return;
260
+ e.preventDefault();
261
+ const to = index + (뒤로 ? 1 : -1);
262
+ if (to < 0 || to >= 낱말수)
263
+ return; // 양 끝 — 아무 일도 하지 않는다
264
+ 옮기기(index, to);
265
+ }, [onReorder, 낱말수, 옮기기]);
266
+ /**
267
+ * **손가락으로 옮긴다** — 길게 누르면(400ms) 끌기가 시작되고 손가락 밑의 칸으로 떨어진다.
268
+ *
269
+ * 마우스는 손대지 않는다. 기존 HTML5 드래그 앤 드롭이 그대로 맡는다 —
270
+ * 두 길이 같은 포인터를 두고 다투면 끌기가 중간에 끊긴다.
271
+ */
272
+ const 길게눌러끌기 = useCallback((e, index) => {
273
+ if (!onReorder || e.pointerType === 'mouse')
274
+ return;
275
+ /* 칸 안 단추를 길게 누른 것은 그 단추 몫이다(재생·정보·빼기) — 옮기기 손잡이만 예외.
276
+ 안 가르면 발음을 들으려고 꾹 누른 손가락에 칸이 끌려간다. */
277
+ const 눌린단추 = e.target.closest?.('button');
278
+ if (눌린단추 && !눌린단추.closest('.word-table-block__card-handle'))
279
+ return;
280
+ 풀기.current?.();
281
+ const 시작 = { x: e.clientX, y: e.clientY };
282
+ let 익음 = false;
283
+ /* 끌기 중에는 화면이 따라 스크롤되면 안 된다. React 가 다는 touchmove 는 passive 라
284
+ 거기서는 막을 수 없어, 익은 순간 직접 단다(passive:false). */
285
+ const 스크롤막기 = (ev) => { if (익음 && ev.cancelable)
286
+ ev.preventDefault(); };
287
+ const 이동 = (ev) => {
288
+ if (!익음) {
289
+ if (Math.abs(ev.clientX - 시작.x) > 움직임문턱_PX || Math.abs(ev.clientY - 시작.y) > 움직임문턱_PX)
290
+ 해제();
291
+ return;
292
+ }
293
+ /* 손가락 밑의 칸. `elementFromPoint` 가 없는 환경(구형·시험)에서는 자리를 못 고른다 —
294
+ 그때는 떨어뜨려도 아무 일이 없다(끌기가 시작되지 않은 것과 같다). */
295
+ const 밑 = document.elementFromPoint?.(ev.clientX, ev.clientY);
296
+ const 칸 = 밑?.closest?.('[data-wordtable-cell]');
297
+ const i = 칸 ? Number(칸.dataset.wordtableCell) : NaN;
298
+ const 자리 = Number.isInteger(i) ? i : null;
299
+ 떨굴자리.current = 자리;
300
+ setDragOver(자리);
301
+ };
302
+ const 뗌 = () => {
303
+ const 자리 = 떨굴자리.current;
304
+ const 끌었다 = 익음;
305
+ 해제();
306
+ if (끌었다 && 자리 != null && 자리 !== index)
307
+ 옮기기(index, 자리);
308
+ };
309
+ const 타이머 = setTimeout(() => {
310
+ 익음 = true;
311
+ set끌기(index);
312
+ 떨굴자리.current = index;
313
+ setDragOver(index);
314
+ window.addEventListener('touchmove', 스크롤막기, { passive: false });
315
+ }, 길게누름_MS);
316
+ function 해제() {
317
+ clearTimeout(타이머);
318
+ window.removeEventListener('pointermove', 이동);
319
+ window.removeEventListener('pointerup', 뗌);
320
+ window.removeEventListener('pointercancel', 뗌);
321
+ window.removeEventListener('touchmove', 스크롤막기);
322
+ 익음 = false;
323
+ 떨굴자리.current = null;
324
+ 풀기.current = null;
325
+ set끌기(null);
326
+ setDragOver(null);
327
+ }
328
+ 풀기.current = 해제;
329
+ window.addEventListener('pointermove', 이동);
330
+ window.addEventListener('pointerup', 뗌);
331
+ window.addEventListener('pointercancel', 뗌);
332
+ }, [onReorder, 옮기기]);
185
333
  const { width, height } = WORD_TABLE_PAPER[orientation];
186
334
  // 스케일러 — 용지(width 고정)를 컨테이너 폭에 맞춰 축소 + 가운데 정렬. fitHeight면 높이도 함께 맞춘다.
187
335
  const { scalerRef, scale, offset } = useFitScale(width, fitHeight ? height : undefined);
@@ -200,16 +348,26 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
200
348
  const contentClass = cx('word-table-block__content', CUT_CLASS[cutLine], showOutline && 'word-table-block__content--outline', showDivLine && hasPairs && 'word-table-block__content--div-line');
201
349
  // 빈칸(추가버튼)은 스케일 역보정으로 실제 크기 유지
202
350
  const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
203
- const grid = (_jsx("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: _jsx("div", {
204
- /* title 로 Section 에 감쌀 때는 className 을 바깥 한 곳에만 — 두 곳에 붙어 소비자 스타일이 두 번 걸렸다 */
205
- className: cx('word-table', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: contentRef, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
206
- // 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
207
- colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
208
- onReorder(dragIndex.current, index);
209
- } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [(item.words ?? []).map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, (item.words ?? []).map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
210
- const isWithinCount = (items?.length ?? 0) + i < cellCount;
211
- return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
212
- })] })), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: `word-table__page-number body-3 word-table__page-number--${pageNumberPosition}`, children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] }) }) }));
351
+ const grid = (_jsxs("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: [_jsx("div", {
352
+ /* title 로 Section 에 감쌀 때는 className 을 바깥 한 곳에만 — 두 곳에 붙어 소비자 스타일이 두 번 걸렸다 */
353
+ className: cx('word-table', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: 내용ref, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
354
+ // 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
355
+ colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { "data-wordtable-cell": onReorder ? index : undefined, className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}${끌기 === index ? ' word-table-block__card--dragging' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onPointerDown: onReorder ? (e) => 길게눌러끌기(e, index) : undefined, onKeyDown: onReorder ? (e) => 키로옮기기(e, index) : undefined, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
356
+ onReorder(dragIndex.current, index);
357
+ } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [(item.words ?? []).map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, (item.words ?? []).map(w => w.label).join(' / ')), onReorder && (
358
+ /* **옮기기 손잡이.** 키보드로 순서를 바꾸려면 초점이 갈 자리가 있어야 하는데,
359
+ 칸 자체를 초점 대상으로 만들면 칸마다 뜻 없는 탭 정지점이 하나씩 늘고
360
+ 낭독기는 그것이 무엇인지 말할 수 없다. 이름 붙은 진짜 단추를 둔다 —
361
+ "끌 수 있다"는 것을 눈으로도 알려 주는 몫까지 겸한다. */
362
+ _jsx("div", { className: "word-table-block__card-handle", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "grip-vertical", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('wordTable.reorder.hint', 'Alt + ← → 로 순서 바꾸기'),
363
+ /* 표에 손잡이가 열둘이면 "버튼" 만으로는 어느 칸인지 알 길이 없다 — 낱말을 이름에 넣는다 */
364
+ ariaLabel: t('wordTable.reorder.handle', {
365
+ word: (item.words ?? []).map(w => w.label).join(' / '),
366
+ defaultValue: '{{word}} 순서 바꾸기 (Alt + 화살표)',
367
+ }) }) })), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
368
+ const isWithinCount = (items?.length ?? 0) + i < cellCount;
369
+ return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
370
+ })] })), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: `word-table__page-number body-3 word-table__page-number--${pageNumberPosition}`, children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] }) }), onReorder && _jsx("span", { role: "status", style: 보이지않게, children: 옮김알림 })] }));
213
371
  if (wrapped) {
214
372
  return (_jsx(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: grid }));
215
373
  }
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react';
1
2
  import type { MonthBarData } from './barChartData';
2
3
  /** @public */
3
4
  export interface BarChartControl {
@@ -85,6 +86,11 @@ export interface BarChartProps {
85
86
  loading?: boolean;
86
87
  /** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
87
88
  onDetail?: () => void;
89
+ /** 머리 오른쪽 묶음에서 기간 컨트롤과 '상세보기' **사이** 에 놓을 것 — 앱이 카드 머리에 얹는 작은 조작(예: 시간/정답률 SegmentedControl size 32).
90
+ * size='small' 에서도 이것만은 그린다(기간 컨트롤은 그대로 숨김). */
91
+ headExtra?: ReactNode;
92
+ /** 카드(section)에 얹을 클래스 — 격자에서 칸 수를 잡을 때(`grid-column`) 감싸개 div 없이. RingChart 와 같은 뜻. */
93
+ className?: string;
88
94
  /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
89
95
  inGrid?: boolean;
90
96
  }
@@ -2,8 +2,22 @@
2
2
  * 훈련 팝업을 **보고 있던** 시간을 기록합니다.
3
3
  * 보인 시간이 5초 미만(잘못 열었다 닫은 경우)이면 무시합니다.
4
4
  *
5
- * 한 팝업에 **한 줄** 만 보냅니다 — 떠날 때, 또는 페이지가 내려갈 때(pagehide: 팝업을 연 채 탭·앱을 닫음). 화면이 가려진
6
- * 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간 잠가 둔 시간이 훈련 시간으로 적혔다.
5
+ * 화면이 가려진 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간
6
+ * 잠가 둔 시간이 훈련 시간으로 적혔다.
7
+ *
8
+ * **언제 보내는가**(사용자 결정 2026-09-16):
9
+ * - 떠날 때(팝업을 닫아 훅이 걷힐 때) 또는 페이지가 내려갈 때(pagehide).
10
+ * - **숨길 때도**(visibilitychange → hidden) 아직 안 보냈으면 그때까지 모은 시간을 보낸다.
11
+ * 모바일에서 팝업을 연 채 앱을 스와이프로 닫으면 pagehide 가 안 오는 기기가 있어
12
+ * (visibilitychange 는 온다) 그 판의 시간이 통째로 사라졌다.
13
+ * - 보낸 뒤 돌아와 시간이 더 쌓이면 떠날 때 **더 쌓인 만큼만** 한 줄 더 보낸다(0 이면 안 보냄).
14
+ * 두 줄은 시간이 겹치지 않아 서버가 합산하면 총합이 맞는다.
15
+ *
16
+ * 그래서 **숨기지 않고 그냥 닫는 평범한 흐름은 여전히 한 줄** 이다 — 보이는 구간마다 따로
17
+ * 보내면 서버가 줄 수를 세션 수로 세어(v_user_training_time_stats) 탭을 오갈 때마다 훈련
18
+ * 횟수가 늘었다(0.147 에서 고친 것). 줄이 둘 이상 나오는 건 실제로 앱이 내려갈 뻔한 판뿐이다.
19
+ *
20
+ * 말귀(소비자) 할 일: 서버가 같은 판의 여러 줄을 **합산** 하는지 확인해야 한다.
7
21
  *
8
22
  * @public
9
23
  */