@ihabdevteam/core 0.186.0 → 0.187.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.
@@ -1606,7 +1606,7 @@ export declare function copyText(text: string): Promise<boolean>;
1606
1606
  *
1607
1607
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1608
1608
  */
1609
- export declare const CORE_VERSION = "0.186.0";
1609
+ export declare const CORE_VERSION = "0.187.0";
1610
1610
 
1611
1611
  /** @public */
1612
1612
  declare const coreDefault: Record<string, unknown>;
@@ -1689,9 +1689,12 @@ export declare type CoreScopeStorageConfig = {
1689
1689
  /**
1690
1690
  * 72×72 정사각 타일. 좌상단에 아이콘, 우하단에 숫자를 배치한다.
1691
1691
  * 훈련 카드의 타입별 문항 수 표시 등에 사용.
1692
+ *
1693
+ * 마우스를 얹으면 `title` 이 툴팁으로 뜬다 — 늦춤은 다른 자리와 같은 상수를 쓴다
1694
+ * (지나가다 스치는 것에 번쩍이지 않게). 키보드로 옮겨 와도(누를 수 있는 타일) 뜬다.
1692
1695
  * @public
1693
1696
  */
1694
- export declare function CountTile({ icon, count, title, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): JSX.Element;
1697
+ export declare function CountTile({ icon, count, title, tooltip, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): JSX.Element;
1695
1698
 
1696
1699
  /** @public */
1697
1700
  export declare interface CountTileProps {
@@ -1699,8 +1702,23 @@ export declare interface CountTileProps {
1699
1702
  icon: string | ReactNode;
1700
1703
  /** 우측 하단 숫자 */
1701
1704
  count: number | string;
1702
- /** 접근성 라벨 (예: 훈련 타입 이름) */
1703
- title?: string;
1705
+ /**
1706
+ * 갈래 이름 — 낭독기가 읽는 이름이자 **눈으로 보는 사람에게 뜨는 툴팁의 글**이다.
1707
+ *
1708
+ * **필수다.** 72×72 안에는 아이콘과 수뿐이라, 이 값이 없으면 그 아이콘이 무슨 갈래인지
1709
+ * 알 길이 화면에도 낭독기에도 없다(말귀 '나의 단어훈련' 카드에 타일이 열 개 선다).
1710
+ * 예전에는 선택값이었고 `aria-label` 로만 나가, 마우스 쓰는 사람은 **아무것도 못 봤다**
1711
+ * (사용자 지시 2026-09-23: "호버시 tooltip 은 무조건 나와야 해").
1712
+ */
1713
+ title: string;
1714
+ /**
1715
+ * 호버 툴팁을 끈다(기본 켬).
1716
+ *
1717
+ * 부르는 쪽이 **제 툴팁**을 띄우는 자리에서만 쓴다 — 말귀의 숙제 확인창 미리보기는
1718
+ * `onMouseEnter` 로 무엇이 담겼는지 보여 주는 큰 툴팁(`TrainingTooltip`)을 띄우는데,
1719
+ * 여기 기본 툴팁까지 뜨면 둘이 겹친다. 그 자리만 끈다.
1720
+ */
1721
+ tooltip?: boolean;
1704
1722
  className?: string;
1705
1723
  /** 클릭 핸들러 — 설정 시 버튼처럼 동작 */
1706
1724
  onClick?: (e: MouseEvent_2) => void;
@@ -6,8 +6,23 @@ export interface CountTileProps {
6
6
  icon: string | ReactNode;
7
7
  /** 우측 하단 숫자 */
8
8
  count: number | string;
9
- /** 접근성 라벨 (예: 훈련 타입 이름) */
10
- title?: string;
9
+ /**
10
+ * 갈래 이름 — 낭독기가 읽는 이름이자 **눈으로 보는 사람에게 뜨는 툴팁의 글**이다.
11
+ *
12
+ * **필수다.** 72×72 안에는 아이콘과 수뿐이라, 이 값이 없으면 그 아이콘이 무슨 갈래인지
13
+ * 알 길이 화면에도 낭독기에도 없다(말귀 '나의 단어훈련' 카드에 타일이 열 개 선다).
14
+ * 예전에는 선택값이었고 `aria-label` 로만 나가, 마우스 쓰는 사람은 **아무것도 못 봤다**
15
+ * (사용자 지시 2026-09-23: "호버시 tooltip 은 무조건 나와야 해").
16
+ */
17
+ title: string;
18
+ /**
19
+ * 호버 툴팁을 끈다(기본 켬).
20
+ *
21
+ * 부르는 쪽이 **제 툴팁**을 띄우는 자리에서만 쓴다 — 말귀의 숙제 확인창 미리보기는
22
+ * `onMouseEnter` 로 무엇이 담겼는지 보여 주는 큰 툴팁(`TrainingTooltip`)을 띄우는데,
23
+ * 여기 기본 툴팁까지 뜨면 둘이 겹친다. 그 자리만 끈다.
24
+ */
25
+ tooltip?: boolean;
11
26
  className?: string;
12
27
  /** 클릭 핸들러 — 설정 시 버튼처럼 동작 */
13
28
  onClick?: (e: MouseEvent) => void;
@@ -17,6 +32,9 @@ export interface CountTileProps {
17
32
  /**
18
33
  * 72×72 정사각 타일. 좌상단에 아이콘, 우하단에 숫자를 배치한다.
19
34
  * 훈련 카드의 타입별 문항 수 표시 등에 사용.
35
+ *
36
+ * 마우스를 얹으면 `title` 이 툴팁으로 뜬다 — 늦춤은 다른 자리와 같은 상수를 쓴다
37
+ * (지나가다 스치는 것에 번쩍이지 않게). 키보드로 옮겨 와도(누를 수 있는 타일) 뜬다.
20
38
  * @public
21
39
  */
22
- export default function CountTile({ icon, count, title, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): import("react").JSX.Element;
40
+ export default function CountTile({ icon, count, title, tooltip, className, onClick, onMouseEnter, onMouseLeave }: CountTileProps): import("react").JSX.Element;
@@ -1,24 +1,73 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
4
  import { cx } from '../utils/cx';
3
5
  import './CountTile.css';
4
6
  import Icon from './Icon';
7
+ import InfoTooltip from './Tooltip/InfoTooltip';
8
+ import { TRAINING_TOOLTIP_HOVER_DELAY_MS } from './Tooltip/TrainingTooltip';
5
9
  /**
6
10
  * 72×72 정사각 타일. 좌상단에 아이콘, 우하단에 숫자를 배치한다.
7
11
  * 훈련 카드의 타입별 문항 수 표시 등에 사용.
12
+ *
13
+ * 마우스를 얹으면 `title` 이 툴팁으로 뜬다 — 늦춤은 다른 자리와 같은 상수를 쓴다
14
+ * (지나가다 스치는 것에 번쩍이지 않게). 키보드로 옮겨 와도(누를 수 있는 타일) 뜬다.
8
15
  * @public
9
16
  */
10
- export default function CountTile({ icon, count, title, className = '', onClick, onMouseEnter, onMouseLeave }) {
17
+ export default function CountTile({ icon, count, title, tooltip = true, className = '', onClick, onMouseEnter, onMouseLeave }) {
11
18
  const cls = cx('count-tile', onClick && 'count-tile--clickable', className);
12
- return (_jsxs("div", { className: cls, onClick: onClick,
13
- /* role="button" + tabIndex 만 있고 키 처리가 없으면, 키보드 사용자는 여기 멈추고도
14
- 누를 수가 없다 — `<div>` 는 브라우저가 Enter·Space 를 대신 처리해 주지 않는다.
15
- Space 의 기본 스크롤도 막는다(2026-08-28, Badge·Accordion 과 같은 처리). */
16
- onKeyDown: onClick ? (e) => {
17
- if (e.key !== 'Enter' && e.key !== ' ')
18
- return;
19
- /* onClick 이 MouseEvent 를 받으므로 가짜로 만들지 않고 **DOM 이 진짜 클릭을
20
- 내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
21
- e.preventDefault();
22
- e.currentTarget.click();
23
- } : undefined, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, role: onClick ? 'button' : 'img', tabIndex: onClick ? 0 : undefined, "aria-label": title ? `${title} ${count}` : String(count), children: [_jsx(Icon, { icon: icon, size: 20, className: "count-tile__icon" }), _jsx("span", { className: "count-tile__count h5", "aria-hidden": "true", children: count })] }));
19
+ const ref = useRef(null);
20
+ const [anchorRect, setAnchorRect] = useState(null);
21
+ const 늦춤 = useRef(null);
22
+ const 닫기 = useCallback(() => {
23
+ if (늦춤.current) {
24
+ clearTimeout(늦춤.current);
25
+ 늦춤.current = null;
26
+ }
27
+ setAnchorRect(null);
28
+ }, []);
29
+ /* 늦춰서 연다 — 타일이 열 개 붙어 서는 자리라, 지나가기만 해도 뜨면 줄줄이 번쩍인다. */
30
+ const 열기 = useCallback((바로) => {
31
+ if (!tooltip)
32
+ return;
33
+ if (늦춤.current)
34
+ clearTimeout(늦춤.current);
35
+ const 띄우기 = () => setAnchorRect(ref.current?.getBoundingClientRect() ?? null);
36
+ if (바로) {
37
+ 늦춤.current = null;
38
+ 띄우기();
39
+ return;
40
+ }
41
+ 늦춤.current = setTimeout(() => { 늦춤.current = null; 띄우기(); }, TRAINING_TOOLTIP_HOVER_DELAY_MS);
42
+ }, [tooltip]);
43
+ /* 떠난 뒤에 타이머가 터지면 아무도 안 보는 자리에 툴팁이 남는다 */
44
+ useEffect(() => () => { if (늦춤.current)
45
+ clearTimeout(늦춤.current); }, []);
46
+ useEffect(() => { if (!tooltip)
47
+ 닫기(); }, [tooltip, 닫기]);
48
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, className: cls, onClick: onClick ? (e) => { 닫기(); onClick(e); } : undefined,
49
+ /* role="button" + tabIndex 만 있고 키 처리가 없으면, 키보드 사용자는 여기 멈추고도
50
+ 누를 수가 없다 — `<div>` 는 브라우저가 Enter·Space 를 대신 처리해 주지 않는다.
51
+ Space 의 기본 스크롤도 막는다(2026-08-28, Badge·Accordion 과 같은 처리). */
52
+ onKeyDown: onClick ? (e) => {
53
+ if (e.key !== 'Enter' && e.key !== ' ')
54
+ return;
55
+ /* onClick 이 MouseEvent 를 받으므로 가짜로 만들지 않고 **DOM 이 진짜 클릭을
56
+ 내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
57
+ e.preventDefault();
58
+ e.currentTarget.click();
59
+ } : undefined,
60
+ /* **여는 것은 포인터 쪽에서 가른다.** 손가락으로 톡 해도 브라우저는 mouseenter 를 함께
61
+ 내는데, 그렇게 뜬 툴팁은 mouseleave 가 안 와서 안 닫힌다. pointerenter 는 mouseenter
62
+ 보다 먼저 오고 무엇으로 왔는지(`pointerType`)를 들고 오므로, **손가락·펜일 때만 안 연다.**
63
+ "마우스일 때만 연다" 로 뒤집어 쓰지 말 것 — 포인터 이벤트가 없는 환경에서는 그 값이
64
+ 빈 값으로 와서(jsdom 은 `null`) 툴팁이 영영 안 뜬다.
65
+ (pointerdown 에서 닫는 것으로는 못 막는다 — 그 이벤트가 mouseenter 보다 **먼저** 와서,
66
+ 닫은 뒤에 다시 열린다.) */
67
+ onPointerEnter: (e) => { if (e.pointerType === 'touch' || e.pointerType === 'pen')
68
+ 닫기();
69
+ else
70
+ 열기(false); }, onMouseEnter: onMouseEnter, onMouseLeave: (e) => { 닫기(); onMouseLeave?.(e); },
71
+ /* 키보드로 옮겨 온 사람에게도 보여야 한다 — 멈춘 자리라 늦출 까닭이 없어 바로 연다. */
72
+ onFocus: onClick ? () => 열기(true) : undefined, onBlur: onClick ? 닫기 : undefined, role: onClick ? 'button' : 'img', tabIndex: onClick ? 0 : undefined, "aria-label": `${title} ${count}`, children: [_jsx(Icon, { icon: icon, size: 20, className: "count-tile__icon" }), _jsx("span", { className: "count-tile__count h5", "aria-hidden": "true", children: count })] }), anchorRect && _jsx(InfoTooltip, { anchorRect: anchorRect, text: title })] }));
24
73
  }
@@ -81,7 +81,13 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
81
81
  /* 같은 패널의 항목 단추(size "keyboard" = 32)와 같은 높이다(사용자 지시 2026-09-22).
82
82
  전에는 40 이었는데, 그 까닭으로 적힌 "옆 Dropdown·TextField(40)와 맞춘다" 는
83
83
  실제와 달랐다 — 세그먼트를 쓰는 패널(단어표 '표 설정')에는 Dropdown 도 TextField 도
84
- 없고 32 짜리 항목 단추만 있어, 한 줄에 32 와 40 이 섞여 세그먼트만 커 보였다. */
84
+ 없고 32 짜리 항목 단추만 있어, 한 줄에 32 와 40 이 섞여 세그먼트만 커 보였다.
85
+
86
+ **폰에서는 28 로 선다 — 그대로 두기로 했다(사용자 판단 2026-09-22).** 숫자 size 는
87
+ 768px 이하에서 한 급 줄고(32→28) `size="Keyboard"`(32)는 안 줄어, 폰에서만 4px 이
88
+ 어긋난다. 맞추려면 항목 단추를 숫자 32 로 내려야 하는데 그러면 손가락 자리가 28 이
89
+ 된다 — 같은 날 채점 단추를 33 까지 끌어올린 것과 반대 방향이라 어긋남 쪽을 택했다.
90
+ 사다리는 `test/buttonSizeLadder0922.test.ts` 에 못 박혀 있다. */
85
91
  size: 32 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
86
92
  ...(section.allOption ? [{ value: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체'), disabled: section.allOptionDisabled }] : []),
87
93
  ...items.map(item => {
@@ -98,7 +98,13 @@ export default function ListPopup(props) {
98
98
  sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
99
99
  })), [columns, sortKey, sortAsc, handleSort]);
100
100
  const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
101
- return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: 이쪽, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, mobileGridTemplateColumns: mobileGridTemplateColumns, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
101
+ return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: 이쪽, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, mobileGridTemplateColumns: mobileGridTemplateColumns,
102
+ /* 폭 조절은 켜되 **저장 열쇠는 안 준다** — 창은 열 때마다 새로 짓는 것이라 폭이 따라다니면
103
+ 엉뚱하다. 그래서 이 창의 표는 끌어도 localStorage 에 아무 자국이 없고, 끈 폭은 창이 닫히면
104
+ 사라진다. 폰에서 '오늘의 듣기 현황 상세보기' 가 `48px 48px …` 로 눌렸는데 저장소가
105
+ 비어 있어 한동안 원인을 못 찾았던 자리다(2026-09-22) — 손잡이 띠에 스친 손짓이 메모리에만
106
+ 폭을 박은 것이었다. 0.186.0 부터 좁은 화면에서는 손잡이도 없고 끈 폭도 안 쓴다. */
107
+ resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
102
108
  const isSelected = selectedIds != null
103
109
  ? (it.id != null && selectedIds.has(it.id))
104
110
  : (selectedId != null && it.id === selectedId);
@@ -91,6 +91,11 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
91
91
  * 그대로 씌워지므로, 좁은 화면에서는 **만든 사람이 적어 둔 틀**로 돌아가는 편이 맞다 —
92
92
  * 그 틀의 `minmax(…px, …fr)` 밑값이 살아나 칸이 제 폭을 갖고, 넘치는 만큼은 표가
93
93
  * 제 가로 스크롤로 보여 준다. 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다.
94
+ *
95
+ * **좁은 화면의 답은 그 가로 스크롤이다 — 접는 꼴(카드형)은 넣지 않는다(사용자 판단 2026-09-22).**
96
+ * 한 줄을 두 줄 카드로 접는 안과 중요한 칸만 두고 펼치는 안을 함께 올렸으나, 표는 표로 두고
97
+ * 옆으로 미는 쪽을 골랐다. 그러니 담을 수 없을 만큼 좁을 때 칸을 눌러 담으려 드는 코드를
98
+ * 다시 넣지 말 것 — 그게 위에 적은 48px 사건이었다.
94
99
  */
95
100
  const 좁은화면 = useMediaQuery('(max-width: 767px)');
96
101
  const 좁은틀 = mobileGridTemplateColumns && 좁은화면 ? mobileGridTemplateColumns : null;
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.186.0";
49
+ export declare const CORE_VERSION = "0.187.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.186.0';
49
+ export const CORE_VERSION = '0.187.0';