@ihabdevteam/core 0.183.2 → 0.185.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +184 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +5 -5
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +19 -19
- package/dist/ihabdevteam-core.d.ts +9 -3
- package/dist/packages/core/src/components/BottomNav.js +34 -22
- package/dist/packages/core/src/components/Calendar.css +32 -5
- package/dist/packages/core/src/components/Calendar.js +1 -1
- package/dist/packages/core/src/components/ChartSection.css +6 -0
- package/dist/packages/core/src/components/EmptyState.css +7 -4
- package/dist/packages/core/src/components/ErrorState.js +1 -1
- package/dist/packages/core/src/components/FilterPanel.js +5 -2
- package/dist/packages/core/src/components/Header.d.ts +3 -1
- package/dist/packages/core/src/components/Header.js +6 -4
- package/dist/packages/core/src/components/PageTitle.css +7 -3
- package/dist/packages/core/src/components/Popup/Popup.css +15 -6
- package/dist/packages/core/src/components/Section.css +6 -0
- package/dist/packages/core/src/components/SideNav.d.ts +2 -0
- package/dist/packages/core/src/components/SideNav.js +5 -5
- package/dist/packages/core/src/components/Toolbar.css +21 -0
- package/dist/packages/core/src/components/Toolbar.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.css +14 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +30 -4
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +12 -0
- package/dist/packages/core/src/components/Training/WordTablePopup.css +18 -0
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.css +25 -1
- package/dist/packages/core/src/components/WordTable.css +19 -0
- package/dist/packages/core/src/components/WordTable.js +21 -2
- package/dist/packages/core/src/components/useFitScale.js +17 -1
- package/dist/packages/core/src/i18n/locales/en.json +4 -2
- package/dist/packages/core/src/i18n/locales/ko.json +4 -2
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -1600,7 +1600,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1600
1600
|
*
|
|
1601
1601
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1602
1602
|
*/
|
|
1603
|
-
export declare const CORE_VERSION = "0.
|
|
1603
|
+
export declare const CORE_VERSION = "0.185.0";
|
|
1604
1604
|
|
|
1605
1605
|
/** @public */
|
|
1606
1606
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -2734,7 +2734,7 @@ export declare function hasStructureContrast(A: Decomp, B: Decomp): boolean;
|
|
|
2734
2734
|
*
|
|
2735
2735
|
* @public
|
|
2736
2736
|
*/
|
|
2737
|
-
export declare function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer }: {
|
|
2737
|
+
export declare function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer }: {
|
|
2738
2738
|
activeKey?: string | null;
|
|
2739
2739
|
onChange?: (key?: string) => void;
|
|
2740
2740
|
/** 지금 모드의 내비 항목. 데스크톱은 SideNav, 모바일은 BottomNav 가 이 목록을 그린다. */
|
|
@@ -2752,6 +2752,8 @@ export declare function Header({ activeKey, onChange, items, malgwiItems: _malgw
|
|
|
2752
2752
|
onAccount?: () => void;
|
|
2753
2753
|
onSetting?: () => void;
|
|
2754
2754
|
onHelp?: () => void;
|
|
2755
|
+
/** 주면 도움말과 계정 **사이** 에 '버그신고' 를 그린다 — 냅 바닥과 모바일 '…' 메뉴 둘 다. */
|
|
2756
|
+
onFeedback?: () => void;
|
|
2755
2757
|
appMode?: 'malgwi' | 'manager';
|
|
2756
2758
|
onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
|
|
2757
2759
|
/**
|
|
@@ -6082,6 +6084,8 @@ export declare interface SideNavProps {
|
|
|
6082
6084
|
onAccount?: () => void;
|
|
6083
6085
|
onSetting?: () => void;
|
|
6084
6086
|
onHelp?: () => void;
|
|
6087
|
+
/** 주면 도움말과 계정 사이에 '버그신고' 를 그린다. 안 주면 그 자리가 없다(`onDebug` 와 같은 결). */
|
|
6088
|
+
onFeedback?: () => void;
|
|
6085
6089
|
/** 현재 앱 모드 ('malgwi' | 'manager') */
|
|
6086
6090
|
appMode?: 'malgwi' | 'manager';
|
|
6087
6091
|
/** 앱 모드 전환 콜백 */
|
|
@@ -7898,7 +7902,7 @@ export declare interface TrainingItemResult {
|
|
|
7898
7902
|
export declare const TrainingPagePopup: TrainingPagePopupComponent;
|
|
7899
7903
|
|
|
7900
7904
|
/** @public */
|
|
7901
|
-
export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, }: TrainingPagePopupBottomProps): JSX.Element;
|
|
7905
|
+
export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): JSX.Element;
|
|
7902
7906
|
|
|
7903
7907
|
/** @public */
|
|
7904
7908
|
export declare type TrainingPagePopupBottomProps = {
|
|
@@ -7907,6 +7911,8 @@ export declare type TrainingPagePopupBottomProps = {
|
|
|
7907
7911
|
onNext?: () => void;
|
|
7908
7912
|
onResult?: () => void;
|
|
7909
7913
|
stepIndex?: number;
|
|
7914
|
+
/** 결과 창이 떠 있는가 — 뜨는 순간 오디오 미터를 끈다(사용자 지시 2026-09-22). */
|
|
7915
|
+
resultOpen?: boolean;
|
|
7910
7916
|
stepCount?: number;
|
|
7911
7917
|
pageIndex?: number;
|
|
7912
7918
|
/** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
|
|
@@ -23,43 +23,55 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
23
23
|
const nextMode = appMode === 'manager' ? 'malgwi' : 'manager';
|
|
24
24
|
const modeIcon = appMode === 'manager' ? 'arrow-left' : 'arrow-right';
|
|
25
25
|
const modeLabel = appMode === 'manager' ? t('nav.modeLabelMalgwi', '말귀') : t('nav.modeLabelManager', '말귀 매니저');
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/* **항목이 쓸 수 있는 폭은 "…" 유무와 무관한 자리에서 잰다.**
|
|
27
|
+
예전에는 `.bottom-nav__items` 를 쟀는데, 그 상자는 `flex:1` 이라 "…"(구분선 1px + 52px 칸)가
|
|
28
|
+
서면 53px 줄어든다 — 즉 **판정 결과가 다시 잰 값을 바꾸는 순환**이었다. 지난 렌더에 "…" 가
|
|
29
|
+
섰는지 기억해 53 을 되돌리는 보정을 뒀지만, 그 기억이 맞지 않는 길(유틸이 뒤늦게 붙는 화면,
|
|
30
|
+
화면을 옮기며 항목 수가 바뀌는 길)에서는 보정이 어긋나 **다섯 칸을 249px 상자에 그려 양끝이
|
|
31
|
+
잘렸다**(말귀 375px /word-training 실측 — 잘린 만큼은 누를 수도 없다). 화면마다 결과가 달랐다.
|
|
32
|
+
이제 바 전체에서 왼쪽 묶음(모드 단추 + 구분선)만 뺀 값을 쓴다. 이 값은 몇 칸을 그리든,
|
|
33
|
+
"…" 가 섰든 아니든 같다. 그래서 기억도 보정도 필요 없다. */
|
|
34
|
+
const rootRef = useRef(null);
|
|
35
|
+
const [avail, setAvail] = useState(0);
|
|
29
36
|
const itemsLen = items.length;
|
|
30
37
|
useEffect(() => {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
38
|
+
const root = rootRef.current;
|
|
39
|
+
if (!root)
|
|
33
40
|
return;
|
|
34
|
-
|
|
41
|
+
const 재기 = () => {
|
|
42
|
+
const cs = typeof getComputedStyle === 'function' ? getComputedStyle(root) : null;
|
|
43
|
+
const 안쪽 = root.clientWidth - (parseFloat(cs?.paddingLeft ?? '') || 0) - (parseFloat(cs?.paddingRight ?? '') || 0);
|
|
44
|
+
// 왼쪽 묶음은 항목 수와 무관하게 늘 같은 폭이다(모드 전환을 안 쓰면 아예 없다)
|
|
45
|
+
const 왼 = root.querySelector('.bottom-nav__left');
|
|
46
|
+
const 왼구분선 = 왼?.nextElementSibling;
|
|
47
|
+
const 왼폭 = (왼?.offsetWidth ?? 0)
|
|
48
|
+
+ (왼 && 왼구분선?.classList.contains('bottom-nav__divider') ? 왼구분선.offsetWidth : 0);
|
|
49
|
+
setAvail(Math.max(0, 안쪽 - 왼폭));
|
|
50
|
+
};
|
|
51
|
+
재기();
|
|
35
52
|
// ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
|
|
36
53
|
// 그대로 던져 렌더가 통째로 깨진다. 위에서 초기값은 이미 쟀으므로 추적만 포기한다
|
|
37
54
|
// (Popup·CrosswordGame·Tooltip 과 같은 처리).
|
|
38
55
|
if (typeof ResizeObserver === 'undefined')
|
|
39
56
|
return;
|
|
40
|
-
const ro = new ResizeObserver(
|
|
41
|
-
ro.observe(
|
|
57
|
+
const ro = new ResizeObserver(재기);
|
|
58
|
+
ro.observe(root);
|
|
42
59
|
return () => ro.disconnect();
|
|
43
60
|
}, []);
|
|
44
|
-
/* 잰 폭은 "…"(구분선 1px + 52px 칸)가 서 있으면 그만큼 줄어든 값이다. 그 폭으로만 판정하면 같은 폭·같은 항목인데 지나온 길에 따라
|
|
45
|
-
넘침이 달랐다(13개 매니저 메뉴 → 8개 말귀 메뉴로 바꾸면 들어갈 8개 중 하나가 "…" 에 갇힘). 유틸이 없을 때 지난 렌더에 "…" 가
|
|
46
|
-
서 있었다면(넘침 때문이든 그때의 유틸 때문이든) 그 폭을 되돌려 **"…" 없는 폭** 으로 판정하고, 넘치면 "…" 자리를 뺀 폭으로 센다. */
|
|
47
61
|
const MORE_W = BTN_SIZE + 1;
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const 다들어감 = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <=
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
:
|
|
55
|
-
|
|
56
|
-
const overflow = containerWidth > 0 && n < itemsLen;
|
|
62
|
+
/** 폭 w 에 들어가는 칸 수 — n칸은 n*52 + (n-1)*10 을 쓰므로 n <= (w + 10) / 62 */
|
|
63
|
+
const 칸수 = (w) => Math.max(0, Math.floor((w + BTN_GAP) / (BTN_SIZE + BTN_GAP)));
|
|
64
|
+
const 다들어감 = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= avail;
|
|
65
|
+
const n = avail <= 0 ? itemsLen
|
|
66
|
+
: (moreItems.length === 0 && 다들어감) ? itemsLen
|
|
67
|
+
// "…" 가 설 자리를 뺀 폭으로 센다 — 유틸이 있으면 "…" 는 늘 선다
|
|
68
|
+
: Math.min(itemsLen, 칸수(avail - MORE_W));
|
|
69
|
+
const overflow = avail > 0 && n < itemsLen;
|
|
57
70
|
const maxVisible = overflow ? n : itemsLen;
|
|
58
71
|
const visibleItems = items.slice(0, maxVisible);
|
|
59
72
|
const hiddenItems = items.slice(maxVisible);
|
|
60
73
|
// 넘친 nav 항목이 있거나 유틸(moreItems)이 있으면 더보기(…) 버튼을 노출
|
|
61
74
|
const showMore = overflow || moreItems.length > 0;
|
|
62
|
-
useEffect(() => { 더보기섰음Ref.current = showMore; });
|
|
63
75
|
const computeMenuStyle = useCallback((rect, trigger) => ({
|
|
64
76
|
position: 'fixed',
|
|
65
77
|
bottom: window.innerHeight - rect.top + 8,
|
|
@@ -114,7 +126,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
114
126
|
}], [hiddenItems, moreItems, activeKey, onChange, handleCloseMore]);
|
|
115
127
|
// "…" 단추 배지 — 메뉴 안에서만 보이는 개수(넘친 항목 + 유틸)를 닫힌 채로도 알린다.
|
|
116
128
|
const moreBadge = [...hiddenItems, ...moreItems].reduce((sum, it) => sum + badgeOf(it.badgeCount), 0);
|
|
117
|
-
return (_jsxs("nav", { className: "bottom-nav", "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items",
|
|
129
|
+
return (_jsxs("nav", { className: "bottom-nav", ref: rootRef, "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", children: visibleItems.map((it, i) => {
|
|
118
130
|
const count = badgeOf(it.badgeCount);
|
|
119
131
|
return (
|
|
120
132
|
// key 는 선택 prop 이다 — 없으면 자리 번호로(형제 key 가 모두 undefined 가 되던 것)
|
|
@@ -74,8 +74,15 @@
|
|
|
74
74
|
.calendar-day-header:first-child { color: var(--color-error); }
|
|
75
75
|
.calendar-day-header:last-child { color: var(--color-main); }
|
|
76
76
|
|
|
77
|
+
/* **칸은 제 폭을 우기지 않는다.** 예전에는 `min-width: 118px` 이 박혀 있고 그 상한을
|
|
78
|
+
767px 이하에서만 풀었다. 그런데 칸이 좁아지는 것은 창이 아니라 **담긴 자리**가 정한다 —
|
|
79
|
+
768px 태블릿에서 카드 안쪽이 555px 이라(1108px 창에서도 707px) 일곱 칸이 850px 를 우겨
|
|
80
|
+
목·금·토가 조상의 `overflow: hidden` 에 잘려 나갔다(말귀 2026-09-22 실측). 창 기준
|
|
81
|
+
중단점으로는 못 막는 자리라 상한 자체를 걷는다 — 넓은 화면에서는 `1fr` 이 알아서 118 을 넘는다.
|
|
82
|
+
칸을 컨테이너로 삼아, 좁아지면 방문 이름표를 '몇 건' 으로 접는다(아래 컨테이너 질의). */
|
|
77
83
|
.calendar-cell {
|
|
78
|
-
min-width:
|
|
84
|
+
min-width: 0;
|
|
85
|
+
container-type: inline-size;
|
|
79
86
|
min-height: 118px;
|
|
80
87
|
border-radius: var(--radius-s);
|
|
81
88
|
padding: 8px;
|
|
@@ -126,6 +133,28 @@
|
|
|
126
133
|
gap: 4px;
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
/* 몇 건인지 — **좁은 칸에서만** 선다. 넓은 칸에서는 이름표가 그 자리를 한다. */
|
|
137
|
+
.calendar-cell__visit-count {
|
|
138
|
+
display: none;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 4px;
|
|
141
|
+
color: var(--color-main);
|
|
142
|
+
}
|
|
143
|
+
.calendar-cell__visit-dot {
|
|
144
|
+
width: 6px;
|
|
145
|
+
height: 6px;
|
|
146
|
+
border-radius: 50%;
|
|
147
|
+
background: currentColor;
|
|
148
|
+
flex-shrink: 0;
|
|
149
|
+
}
|
|
150
|
+
/* 이름표 한 줄("13시 33분 · 지한")이 담기려면 100px 은 있어야 한다 — 그 밑에서는 접는다.
|
|
151
|
+
창이 아니라 **칸 제 폭**으로 정한다: 같은 창에서도 담긴 카드가 좁으면 칸이 좁아진다.
|
|
152
|
+
재는 값은 칸의 **안쪽**(여백 8px씩 뺀 값)이라, 118px 짜리 칸이면 102 로 이 문턱을 넘는다. */
|
|
153
|
+
@container (max-width: 96px) {
|
|
154
|
+
.calendar-cell__badges { display: none; }
|
|
155
|
+
.calendar-cell__visit-count { display: inline-flex; }
|
|
156
|
+
}
|
|
157
|
+
|
|
129
158
|
.calendar-visit-badge {
|
|
130
159
|
color: var(--color-main);
|
|
131
160
|
background: var(--color-main-a10);
|
|
@@ -235,10 +264,8 @@
|
|
|
235
264
|
justify-content: center;
|
|
236
265
|
}
|
|
237
266
|
|
|
238
|
-
/*
|
|
239
|
-
|
|
240
|
-
토요일 쪽 요일이 **아예 보이지 않았다**). 좁을 땐 상한을 풀어 1fr로 나눠 갖게 한다. */
|
|
241
|
-
.calendar-cell,
|
|
267
|
+
/* 칸의 min-width 는 이제 어느 폭에서도 0 이다(위 `.calendar-cell` 주석) — 여기서는
|
|
268
|
+
요일 머리만 함께 풀고, 좁은 칸의 높이·여백을 줄인다. */
|
|
242
269
|
.calendar-day-header {
|
|
243
270
|
min-width: 0;
|
|
244
271
|
}
|
|
@@ -83,7 +83,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
83
83
|
dragVisitId.current = null;
|
|
84
84
|
}
|
|
85
85
|
setDragOverDate(null);
|
|
86
|
-
} : undefined, children: [_jsx("div", { className: "calendar-cell__number body-3", children: day }), _jsx("div", { className: "calendar-cell__badges", children: dayVisits.map((v, vi) => (_jsxs("div", { draggable: !!onVisitMove, className: `calendar-visit-badge${v.status === 'noVisit' ? ' calendar-visit-badge--no-visit' : v.status === 'scheduled' ? ' calendar-visit-badge--scheduled' : ''}${onVisitClick ? ' calendar-visit-badge--clickable' : ''}${onVisitMove ? ' calendar-visit-badge--draggable' : ''}`, onDragStart: onVisitMove ? (e) => {
|
|
86
|
+
} : undefined, children: [_jsx("div", { className: "calendar-cell__number body-3", children: day }), dayVisits.length > 0 && (_jsxs("span", { className: "calendar-cell__visit-count caption-1", "aria-label": t('calendar.visitCount', { count: dayVisits.length, defaultValue: '방문 {{count}}건' }), children: [_jsx("span", { className: "calendar-cell__visit-dot", "aria-hidden": "true" }), dayVisits.length] })), _jsx("div", { className: "calendar-cell__badges", children: dayVisits.map((v, vi) => (_jsxs("div", { draggable: !!onVisitMove, className: `calendar-visit-badge${v.status === 'noVisit' ? ' calendar-visit-badge--no-visit' : v.status === 'scheduled' ? ' calendar-visit-badge--scheduled' : ''}${onVisitClick ? ' calendar-visit-badge--clickable' : ''}${onVisitMove ? ' calendar-visit-badge--draggable' : ''}`, onDragStart: onVisitMove ? (e) => {
|
|
87
87
|
dragVisitId.current = v.id;
|
|
88
88
|
e.dataTransfer.effectAllowed = 'move';
|
|
89
89
|
} : undefined, onDragEnd: onVisitMove ? () => {
|
|
@@ -86,8 +86,14 @@
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
@media (max-width: 767px) {
|
|
89
|
+
/* **세로로 세울 때는 줄바꿈을 끈다.** 기본 규칙의 `flex-wrap: wrap` 은 가로(row)에서
|
|
90
|
+
오른쪽 묶음을 아랫줄로 내리려고 둔 것인데, 여기서 방향만 세로로 바꾸면 그 줄바꿈이
|
|
91
|
+
**오른쪽 새 칸**을 만든다 — 머리 높이가 정해지는 자리에서 셋째인 설명(`__disc`)이
|
|
92
|
+
아랫줄이 아니라 옆 칸으로 빠져 카드 밖 141px 까지 나갔다(말귀 375px 문장훈련 분석
|
|
93
|
+
갈래 카드 실측 2026-09-22). 세로에서는 줄을 바꿀 일이 없다. */
|
|
89
94
|
.manager-section__head {
|
|
90
95
|
flex-direction: column;
|
|
96
|
+
flex-wrap: nowrap;
|
|
91
97
|
align-items: flex-start;
|
|
92
98
|
gap: var(--spacing-s);
|
|
93
99
|
}
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
빈 화면이 위에 붙어 아래가 휑하던 자리다. 자리가 빠듯하면 auto 가 0 이 되어 예전과 같다. */
|
|
13
13
|
margin-block: auto;
|
|
14
14
|
}
|
|
15
|
-
/* 빈 화면 아이콘 —
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.
|
|
15
|
+
/* 빈 화면 아이콘 — **크기를 여기서 박지 않는다.** 크기는 `Icon size` 한 곳이 정하고(48,
|
|
16
|
+
EmptyState.tsx · ErrorState.tsx) 이 상자는 그 그림에 맞춰 줄어든다.
|
|
17
|
+
상자와 그림이 따로 크기를 가지면 한쪽만 바뀔 때 조용히 어긋난다 — 실제로 두 번 어긋났다:
|
|
18
|
+
0.182.0 은 상자 40 · 그림 20(그림 둘레에 10px 빈 테가 생겨 제목과의 간격까지 벌어졌다),
|
|
19
|
+
0.183.0 은 상자 56 · 그림 20. 둘 다 화면에서 잡혔다(사용자 지시 2026-09-21 "아이콘 크기
|
|
20
|
+
지정하고 있는 것은 걷는다"). inline-flex 만 남기는 것은 글줄 기준선 아래 빈틈을 없애려는 것이다. */
|
|
21
|
+
.empty-state__icon { color: var(--color-lightGray); display: inline-flex; }
|
|
19
22
|
/* 제목과 본문은 **같은 색** 이다(사용자 지시 2026-09-21) — 전에는 제목이 한 단계 옅어(lightGray)
|
|
20
23
|
제목이 본문보다 뒤로 물러나 보였다. 위계는 글자 등급(h4 · body-3)이 준다.
|
|
21
24
|
같은 색으로 모을 때 gray 를 고른 것은 lightGray 가 흰 지면에서 AA(4.5:1)에 못 미치기 때문이다. */
|
|
@@ -9,7 +9,7 @@ import { usePendingPress } from '../hooks/usePendingPress';
|
|
|
9
9
|
export default function ErrorState({ message, hint, icon = 'triangle-alert', onRetry, retryLabel, className }) {
|
|
10
10
|
const { t } = useTranslation();
|
|
11
11
|
const { pending, press } = usePendingPress(onRetry);
|
|
12
|
-
return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "error-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
|
|
12
|
+
return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, size: 48, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "error-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
|
|
13
13
|
// type="button" — 없으면 폼 안에서 다시 시도를 누르는 순간 폼이 제출된다
|
|
14
14
|
type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: press, disabled: pending, "aria-busy": pending || undefined, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
|
|
15
15
|
}
|
|
@@ -78,8 +78,11 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
|
|
|
78
78
|
/* 아무것도 안 골랐으면 빈 값 — undefined 를 넘기면 SegmentedControl 의 기본값(첫 보기)이 걸려, 상태는
|
|
79
79
|
비었는데 첫 보기가 골라진 것처럼 보였다(필터는 걸리지 않았는데 걸린 듯). */
|
|
80
80
|
value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label),
|
|
81
|
-
/*
|
|
82
|
-
|
|
81
|
+
/* 같은 패널의 항목 단추(size "keyboard" = 32)와 같은 높이다(사용자 지시 2026-09-22).
|
|
82
|
+
전에는 40 이었는데, 그 까닭으로 적힌 "옆 Dropdown·TextField(40)와 맞춘다" 는
|
|
83
|
+
실제와 달랐다 — 세그먼트를 쓰는 패널(단어표 '표 설정')에는 Dropdown 도 TextField 도
|
|
84
|
+
없고 32 짜리 항목 단추만 있어, 한 줄에 32 와 40 이 섞여 세그먼트만 커 보였다. */
|
|
85
|
+
size: 32 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
|
|
83
86
|
...(section.allOption ? [{ value: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체'), disabled: section.allOptionDisabled }] : []),
|
|
84
87
|
...items.map(item => {
|
|
85
88
|
const value = typeof item === 'string' ? item : item.value;
|
|
@@ -19,7 +19,7 @@ export declare function BrandIdentity({ appMode }: {
|
|
|
19
19
|
*
|
|
20
20
|
* @public
|
|
21
21
|
*/
|
|
22
|
-
export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer }: {
|
|
22
|
+
export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer }: {
|
|
23
23
|
activeKey?: string | null;
|
|
24
24
|
onChange?: (key?: string) => void;
|
|
25
25
|
/** 지금 모드의 내비 항목. 데스크톱은 SideNav, 모바일은 BottomNav 가 이 목록을 그린다. */
|
|
@@ -37,6 +37,8 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
|
|
|
37
37
|
onAccount?: () => void;
|
|
38
38
|
onSetting?: () => void;
|
|
39
39
|
onHelp?: () => void;
|
|
40
|
+
/** 주면 도움말과 계정 **사이** 에 '버그신고' 를 그린다 — 냅 바닥과 모바일 '…' 메뉴 둘 다. */
|
|
41
|
+
onFeedback?: () => void;
|
|
40
42
|
appMode?: 'malgwi' | 'manager';
|
|
41
43
|
onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
|
|
42
44
|
/**
|
|
@@ -42,7 +42,7 @@ export function BrandIdentity({ appMode }) {
|
|
|
42
42
|
*
|
|
43
43
|
* @public
|
|
44
44
|
*/
|
|
45
|
-
export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer }) {
|
|
45
|
+
export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer }) {
|
|
46
46
|
const { t } = useTranslation();
|
|
47
47
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
48
48
|
/* **모바일 하단 내비에 SideNav 항목을 옮겨 적는다.** 예전에는 `SideNavItem` 을 `NavItem` 으로
|
|
@@ -91,10 +91,12 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
|
|
|
91
91
|
list.push({ key: 'settings', icon: 'settings', label: t('nav.settings', '설정'), onClick: onSetting });
|
|
92
92
|
if (onHelp)
|
|
93
93
|
list.push({ key: 'help', icon: 'message-circle-question-mark', label: t('nav.help', '도움말'), onClick: onHelp });
|
|
94
|
+
if (onFeedback)
|
|
95
|
+
list.push({ key: 'feedback', icon: 'bug', label: t('nav.feedback', '버그신고'), onClick: onFeedback });
|
|
94
96
|
if (onAccount)
|
|
95
97
|
list.push({ key: 'account', icon: 'user-round', label: t('nav.account', '계정'), onClick: onAccount });
|
|
96
98
|
return list;
|
|
97
|
-
}, [t, onDebug, onNotification, onSetting, onHelp, onAccount, unreadCount]);
|
|
99
|
+
}, [t, onDebug, onNotification, onSetting, onHelp, onFeedback, onAccount, unreadCount]);
|
|
98
100
|
// 헤더 햄버거 — SideNav 푸터에 해당하는 유틸(알림·설정·도움말·계정·디버그)을 띄운다.
|
|
99
101
|
// 데스크톱에선 SideNav 푸터에 늘 보이지만 모바일에선 그 자리가 없어, 같은 항목을
|
|
100
102
|
// 하단 더보기(…)와 동일한 구성(bottomMoreItems)으로 재사용한다.
|
|
@@ -116,9 +118,9 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
|
|
|
116
118
|
onClick: () => { it.onClick?.(); closeUtilMenu(); },
|
|
117
119
|
})),
|
|
118
120
|
}], [bottomMoreItems, activeKey, closeUtilMenu]);
|
|
119
|
-
return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: onNotification && unreadCount && unreadCount > 0
|
|
121
|
+
return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", useBorder: false, icon: 'menu', ariaLabel: onNotification && unreadCount && unreadCount > 0
|
|
120
122
|
? t('nav.badgeLabel', { label: t('header.menuOpen', '메뉴 열기'), count: unreadCount, defaultValue: '{{label}}, {{count}}개' })
|
|
121
|
-
: t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), !!onNotification && !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true })), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { scrollContainer: scrollContainer, items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, scrollContainer: scrollContainer, appMode: appMode, onAppModeChange: onAppModeChange })] }));
|
|
123
|
+
: t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), !!onNotification && !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true })), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { scrollContainer: scrollContainer, items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, onFeedback: onFeedback, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, scrollContainer: scrollContainer, appMode: appMode, onAppModeChange: onAppModeChange })] }));
|
|
122
124
|
}
|
|
123
125
|
/**
|
|
124
126
|
* `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
|
|
@@ -20,9 +20,13 @@
|
|
|
20
20
|
}
|
|
21
21
|
.page-title__action {
|
|
22
22
|
margin-left: 0;
|
|
23
|
-
/* 세로로 내려오면
|
|
24
|
-
제목과 어긋나 화면 오른쪽에 홀로 떠
|
|
25
|
-
|
|
23
|
+
/* 세로로 내려오면 **가운데**에 둔다(사용자 지시 2026-09-22).
|
|
24
|
+
한때는 `flex-start` 였다 — 오른쪽에 붙여 두면 제목과 어긋나 화면 오른쪽에 홀로 떠
|
|
25
|
+
있는 것처럼 보였기 때문이다. 그 진단은 맞았지만 답이 한 칸 모자랐다: 왼쪽에 붙이면
|
|
26
|
+
제목 줄과는 맞아도, 여기 서는 것(세그먼트 고르개·단추 하나)은 제목처럼 줄글이 아니라
|
|
27
|
+
**덩어리**라 한쪽에 몰린 것으로 읽힌다. 가운데가 그 둘 사이다.
|
|
28
|
+
실측(375px, 제목 줄 335px · 고르개 160px): 왼쪽 20~180 → 가운데 107~267. */
|
|
29
|
+
align-self: center;
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
|
|
@@ -63,9 +63,12 @@
|
|
|
63
63
|
gap: var(--spacing-xs);
|
|
64
64
|
}
|
|
65
65
|
.popup-title { padding: var(--spacing-l); display: block; text-align: center; flex-shrink: 0; }
|
|
66
|
-
|
|
66
|
+
/* 좁아지면 **오른쪽 묶음이 아랫줄로 내려간다**. 예전에는 묶음이 `flex-shrink: 0` 이라 제 속 너비를
|
|
67
|
+
끝까지 우기고 제목 칸만 0 까지 눌려, 좁은 화면에서 제목이 한 글자씩 세로로 쏟아졌다
|
|
68
|
+
(말귀 2026-09-22 실측). 제목에 밑바닥(220px)을 줘 그 아래로는 줄을 바꾸게 한다. */
|
|
69
|
+
.popup-header { padding: var(--spacing-l) var(--spacing-l) 0; display: flex; align-items: center; flex-wrap: wrap; gap: var(--spacing-s); flex-shrink: 0; }
|
|
67
70
|
.popup-header--with-subtitle { align-items: flex-start; }
|
|
68
|
-
.popup-header-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
71
|
+
.popup-header-main { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
69
72
|
.popup-title--left { padding: 0; text-align: left; flex: 1; min-width: 0; }
|
|
70
73
|
.popup-header-main .popup-title--left { flex: none; }
|
|
71
74
|
.popup-header .popup-title--left { margin: 0; }
|
|
@@ -73,7 +76,9 @@
|
|
|
73
76
|
/* 머리 오른쪽 고르개(headerSegment) — 닫기·headerActions 앞. 좁으면 제목 아래로 줄을 바꾸고
|
|
74
77
|
제목을 밀어내지 않는다(제목 칸이 flex:1 로 먼저 줄어든다). */
|
|
75
78
|
.popup-header-segment { flex-shrink: 0; }
|
|
76
|
-
|
|
79
|
+
/* 줄이 바뀌면 오른쪽 끝에 선다. 안에서도 한 번 더 줄을 바꿀 수 있어야 한다 — 앱이 고르개처럼
|
|
80
|
+
넓은 것을 얹는 자리다. 닫기(✕)는 어느 경우에도 이 묶음 안에 있어 손이 닿는다. */
|
|
81
|
+
.popup-header-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin-left: auto; min-width: 0; }
|
|
77
82
|
.popup-content {
|
|
78
83
|
padding: 20px 20px 0;
|
|
79
84
|
flex: 1;
|
|
@@ -91,7 +96,9 @@
|
|
|
91
96
|
}
|
|
92
97
|
/* 세로는 가운데 — 안 적어 두면 stretch 라, 키가 다른 것(보조 글월 + 단추)을 나란히 두면 늘어난다.
|
|
93
98
|
소비자 셋이 이 자리를 각자 `align-items: center` 로 칠하고 있었다(2026-09-17 말귀 전수). */
|
|
94
|
-
|
|
99
|
+
/* 좁으면 **줄을 바꾼다.** 안 바꾸면 단추 셋(120×3 + 사이 = 408px)이 353px 바닥에 그대로 서서
|
|
100
|
+
마지막 단추의 오른쪽 55px 이 잘렸다 — 잘린 쪽은 눌리지도 않는다(말귀 375px 채점 창 실측). */
|
|
101
|
+
.popup-footer { padding: 0 20px 20px; display:flex; flex-wrap: wrap; gap:8px; justify-content:center; align-items:center }
|
|
95
102
|
/* 바닥 배치(footerLayout) */
|
|
96
103
|
.popup-footer--fill > * { flex: 1 }
|
|
97
104
|
.popup-footer--between {
|
|
@@ -99,8 +106,10 @@
|
|
|
99
106
|
padding-left: var(--spacing-xl);
|
|
100
107
|
padding-right: var(--spacing-xl);
|
|
101
108
|
}
|
|
102
|
-
/* 표준 buttons 배열만 폭/정렬 제약 — 커스텀 footer는 자유
|
|
103
|
-
|
|
109
|
+
/* 표준 buttons 배열만 폭/정렬 제약 — 커스텀 footer는 자유 배치.
|
|
110
|
+
`flex: 1` 의 기본 바닥(min-width: auto)은 글자 길이만큼 버틴다 — 0 으로 내려 줄이 바뀌기 전에
|
|
111
|
+
먼저 줄어들게 하고, 그래도 모자라면 위의 `flex-wrap` 이 아랫줄로 내린다. */
|
|
112
|
+
.popup-footer--buttons > * { max-width: 200px; flex: 1 1 auto; min-width: 0; }
|
|
104
113
|
.popup--closing { opacity: 0; transition: opacity 260ms ease }
|
|
105
114
|
|
|
106
115
|
@media (max-width: 767px) {
|
|
@@ -28,11 +28,15 @@
|
|
|
28
28
|
width: 100%;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/* **줄어들 수 있어야 한다.** flex 항목의 min-width 기본값은 auto 라, 설명(`disc`)이 길면
|
|
32
|
+
그 자연 폭만큼 버텨 카드 밖으로 밀려 나갔다(말귀 375px 분석 창 갈래 카드 실측 2026-09-22).
|
|
33
|
+
글은 줄을 바꾸면 되는 것이라 바닥을 0 으로 내린다. */
|
|
31
34
|
.section__title-row {
|
|
32
35
|
display: flex;
|
|
33
36
|
align-items: flex-start;
|
|
34
37
|
gap: var(--spacing-s);
|
|
35
38
|
flex: 1;
|
|
39
|
+
min-width: 0;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
.section__icon {
|
|
@@ -52,6 +56,8 @@
|
|
|
52
56
|
display: flex;
|
|
53
57
|
flex-direction: column;
|
|
54
58
|
gap: 4px;
|
|
59
|
+
/* 위와 같은 까닭 — 제목·설명이 담긴 칸도 줄어들 수 있어야 한다 */
|
|
60
|
+
min-width: 0;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
.section__title {
|
|
@@ -23,6 +23,8 @@ export interface SideNavProps {
|
|
|
23
23
|
onAccount?: () => void;
|
|
24
24
|
onSetting?: () => void;
|
|
25
25
|
onHelp?: () => void;
|
|
26
|
+
/** 주면 도움말과 계정 사이에 '버그신고' 를 그린다. 안 주면 그 자리가 없다(`onDebug` 와 같은 결). */
|
|
27
|
+
onFeedback?: () => void;
|
|
26
28
|
/** 현재 앱 모드 ('malgwi' | 'manager') */
|
|
27
29
|
appMode?: 'malgwi' | 'manager';
|
|
28
30
|
/** 앱 모드 전환 콜백 */
|
|
@@ -14,7 +14,7 @@ function nameWithCount(t, label, count) {
|
|
|
14
14
|
}
|
|
15
15
|
function SideNavFooter(props) {
|
|
16
16
|
const { t } = useTranslation();
|
|
17
|
-
const { activeKey, unreadCount, onDebug, onNotification, onSetting, onHelp, onAccount } = props || {};
|
|
17
|
+
const { activeKey, unreadCount, onDebug, onNotification, onSetting, onHelp, onFeedback, onAccount } = props || {};
|
|
18
18
|
const variant = 'default';
|
|
19
19
|
/* **핸들러를 준 것만 그린다.**
|
|
20
20
|
예전에는 디버그만 이 규칙을 따르고 나머지 넷은 핸들러와 무관하게 늘 그렸다.
|
|
@@ -22,11 +22,11 @@ function SideNavFooter(props) {
|
|
|
22
22
|
넷 서 있었다 — 눌러도 아무 일이 없으니 사람은 고장으로 읽고, 낭독기에는
|
|
23
23
|
'알림 단추' 라고 읽히는데 실제로는 아무 데도 못 간다.
|
|
24
24
|
**없는 단추가 죽은 단추보다 낫다.** */
|
|
25
|
-
const 그릴것 = onDebug || onNotification || onSetting || onHelp || onAccount;
|
|
25
|
+
const 그릴것 = onDebug || onNotification || onSetting || onHelp || onFeedback || onAccount;
|
|
26
26
|
// 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
|
|
27
27
|
if (!그릴것)
|
|
28
28
|
return null;
|
|
29
|
-
return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
|
|
29
|
+
return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onFeedback && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bug", ariaLabel: t('nav.feedback', '버그신고'), onClick: onFeedback, selected: activeKey === 'feedback' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
|
|
30
30
|
}
|
|
31
31
|
function SideNavButton(props) {
|
|
32
32
|
const { t } = useTranslation();
|
|
@@ -58,7 +58,7 @@ function SideNavButton(props) {
|
|
|
58
58
|
/** @public */
|
|
59
59
|
export default function SideNav(props) {
|
|
60
60
|
const { t } = useTranslation();
|
|
61
|
-
const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer } = props || {};
|
|
61
|
+
const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer } = props || {};
|
|
62
62
|
/* 모드를 안 주면 말귀로 본다 — Header(브랜드)·BottomNav 와 같게. 전에는 여기만 매니저로 봐 같은 화면에서
|
|
63
63
|
데스크톱은 '말귀로 이동', 모바일은 '말귀 매니저' 로 서로 반대를 가리켰다. */
|
|
64
64
|
const isManager = appMode === 'manager';
|
|
@@ -118,5 +118,5 @@ export default function SideNav(props) {
|
|
|
118
118
|
key: '__mode-switch__',
|
|
119
119
|
label: isManager ? t('nav.goToMalgwi', '말귀로 이동') : t('nav.goToManager', '말귀매니저로 이동'),
|
|
120
120
|
icon: isManager ? 'arrow-left' : 'arrow-right',
|
|
121
|
-
}, onClick: handleModeSwitch }))] }), _jsx(SideNavFooter, { activeKey: activeKey, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp })] }));
|
|
121
|
+
}, onClick: handleModeSwitch }))] }), _jsx(SideNavFooter, { activeKey: activeKey, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, onFeedback: onFeedback })] }));
|
|
122
122
|
}
|
|
@@ -38,6 +38,27 @@
|
|
|
38
38
|
gap: var(--spacing-s);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/* **안쪽 묶음이 줄 전체로 늘어나면 `justify-content` 가 먹을 여지가 없다.**
|
|
42
|
+
우측 묶음의 한 자식(예: 기간 조작 `.manager-section-controls`)이 제 안에서 줄을 바꾸면,
|
|
43
|
+
그 자식의 폭이 줄 전체가 되어(375px 에서 335px) 안의 항목들이 **왼쪽부터** 깔렸다 —
|
|
44
|
+
우측 묶음은 오른쪽에 붙었는데 그 안은 왼쪽에 붙는 꼴이다(말귀 실측 2026-09-22).
|
|
45
|
+
늘어나는 것 자체는 막지 않는다(막으면 좁은 화면에서 그 묶음이 제 줄을 못 쓴다).
|
|
46
|
+
대신 **늘어나도 안쪽이 오른쪽에 서게** 한다 — 원래 오른쪽 것이던 자리다. */
|
|
47
|
+
.toolbar__row-end > * {
|
|
48
|
+
justify-content: flex-end;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* 페이지 크기 + 쪽수 — 한 덩어리로 움직인다. 안에서는 줄을 바꾸지 않는다(`nowrap`):
|
|
52
|
+
둘이 갈라지면 "50개씩 보기" 만 남고 쪽수가 다음 줄로 떨어져, 같은 것을 다루는 짝이
|
|
53
|
+
따로 노는 것처럼 보였다(말귀 실측 2026-09-22). 좁으면 이 덩어리가 통째로 내려간다. */
|
|
54
|
+
.toolbar__pager {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: var(--spacing-s);
|
|
58
|
+
flex-wrap: nowrap;
|
|
59
|
+
flex-shrink: 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
.toolbar__count {
|
|
42
63
|
flex-shrink: 0;
|
|
43
64
|
margin: 0;
|
|
@@ -18,5 +18,5 @@ export default function Toolbar({ count, search, segment, filters, pageSize, pag
|
|
|
18
18
|
/* placeholder는 접근 가능한 이름이 아니다 — 입력이 시작되면 사라지고
|
|
19
19
|
화면낭독기 지원도 제각각이라, 같은 문구를 aria-label로도 준다.
|
|
20
20
|
(툴바 검색은 시각적 라벨이 따로 없는 자리라 이게 유일한 이름이 된다.) */
|
|
21
|
-
ariaLabel: search.placeholder ?? t('common.search', '검색'), clearable: true })), filters?.map((f, i) => (_jsx(Dropdown, { size: 40, options: f.options, value: f.value, onChange: f.onChange, ariaLabel: f.ariaLabel }, i))), pageSize && _jsx(Dropdown, { size: 40, options: pageSize.options, value: pageSize.value, onChange: pageSize.onChange, ariaLabel: pageSize.ariaLabel ?? t('common.pageSizeSelect', '페이지당 표시 개수') }), pagination && (_jsx(Pagination, { page: pagination.page, totalPages: pagination.total, onChange: pagination.onChange, prevLabel: pagination.prevLabel, nextLabel: pagination.nextLabel })), actions] }) }))] }));
|
|
21
|
+
ariaLabel: search.placeholder ?? t('common.search', '검색'), clearable: true })), filters?.map((f, i) => (_jsx(Dropdown, { size: 40, options: f.options, value: f.value, onChange: f.onChange, ariaLabel: f.ariaLabel }, i))), (pageSize || pagination) && (_jsxs("div", { className: "toolbar__pager", children: [pageSize && _jsx(Dropdown, { size: 40, options: pageSize.options, value: pageSize.value, onChange: pageSize.onChange, ariaLabel: pageSize.ariaLabel ?? t('common.pageSizeSelect', '페이지당 표시 개수') }), pagination && (_jsx(Pagination, { page: pagination.page, totalPages: pagination.total, onChange: pagination.onChange, prevLabel: pagination.prevLabel, nextLabel: pagination.nextLabel }))] })), actions] }) }))] }));
|
|
22
22
|
}
|
|
@@ -905,7 +905,9 @@ function TrainingPagePopupInner(props) {
|
|
|
905
905
|
아무 데도 닿지 않았다** — 낭독기는 이것을 그냥 대화상자로 읽고 가상 커서가
|
|
906
906
|
뒤 화면으로 새어 나간다(Tab 가둠은 `useFocusTrap` 이 따로 하고 있었다).
|
|
907
907
|
둘을 한 요소에 모은다. */
|
|
908
|
-
_jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount
|
|
908
|
+
_jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount,
|
|
909
|
+
/* 결과 창이 뜨면 바닥의 오디오 미터를 끈다 — 결과를 보는 동안 떠 있을 까닭이 없다 */
|
|
910
|
+
resultOpen: Boolean(resultOverlay && !resultOverlay.isEmpty) })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
|
|
909
911
|
score: resultOverlay.score,
|
|
910
912
|
results: (resultOverlay.results || resultOverlay.answers || []),
|
|
911
913
|
contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
|
|
@@ -43,3 +43,17 @@
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
.pp-bottom__tooltip-trigger { display: inline-block; }
|
|
46
|
+
|
|
47
|
+
/* 폰에서 토글 단추 위에 떠서 보이는 미터 — 바닥 줄에 나란히 두면 미터 두 줄(222px)이
|
|
48
|
+
'다음 문제'(150px)를 화면 밖으로 밀어낸다(말귀 375px 실측: 보이는 폭 5px, 바닥이
|
|
49
|
+
overflow:visible 이라 굴려 꺼낼 수도 없었다). 떠 있는 판에서는 두 줄을 세로로 쌓는다. */
|
|
50
|
+
.pp-bottom__meters--floating {
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: var(--spacing-xs);
|
|
54
|
+
}
|
|
55
|
+
/* 토글 단추 자리 — 떠 있는 판의 기준점이라 흐름에서 제 크기를 지킨다 */
|
|
56
|
+
.pp-bottom__meter-trigger {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
}
|
|
@@ -6,10 +6,12 @@ export type TrainingPagePopupBottomProps = {
|
|
|
6
6
|
onNext?: () => void;
|
|
7
7
|
onResult?: () => void;
|
|
8
8
|
stepIndex?: number;
|
|
9
|
+
/** 결과 창이 떠 있는가 — 뜨는 순간 오디오 미터를 끈다(사용자 지시 2026-09-22). */
|
|
10
|
+
resultOpen?: boolean;
|
|
9
11
|
stepCount?: number;
|
|
10
12
|
pageIndex?: number;
|
|
11
13
|
/** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
|
|
12
14
|
pageCount?: number;
|
|
13
15
|
};
|
|
14
16
|
/** @public */
|
|
15
|
-
export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, }: TrainingPagePopupBottomProps): import("react").JSX.Element;
|
|
17
|
+
export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): import("react").JSX.Element;
|