@ihabdevteam/core 0.127.0 → 0.130.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 +141 -0
- package/dist/components.cjs.js +178 -178
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core-tokens.css +1 -1
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.css +1 -1
- package/dist/ihabdevteam-core.d.ts +276 -66
- package/dist/packages/core/src/components/AudioMeter.js +29 -15
- package/dist/packages/core/src/components/Avatar.js +6 -2
- package/dist/packages/core/src/components/Badge.d.ts +5 -1
- package/dist/packages/core/src/components/Badge.js +19 -5
- package/dist/packages/core/src/components/BulkBar.d.ts +2 -1
- package/dist/packages/core/src/components/BulkBar.js +9 -2
- package/dist/packages/core/src/components/Calendar.js +1 -1
- package/dist/packages/core/src/components/ColorPicker.d.ts +3 -1
- package/dist/packages/core/src/components/ColorPicker.js +4 -2
- package/dist/packages/core/src/components/DatePicker.d.ts +2 -1
- package/dist/packages/core/src/components/DatePicker.js +9 -4
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.d.ts +9 -0
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +30 -25
- package/dist/packages/core/src/components/EmptyState.js +3 -1
- package/dist/packages/core/src/components/ErrorState.js +3 -1
- package/dist/packages/core/src/components/FilterPanel.js +7 -0
- package/dist/packages/core/src/components/Header.css +8 -0
- package/dist/packages/core/src/components/Header.d.ts +6 -0
- package/dist/packages/core/src/components/Header.js +25 -1
- package/dist/packages/core/src/components/Icon.css +22 -0
- package/dist/packages/core/src/components/Icon.d.ts +7 -3
- package/dist/packages/core/src/components/Icon.js +7 -1
- package/dist/packages/core/src/components/IconGridMenu.css +4 -2
- package/dist/packages/core/src/components/IconGridMenu.d.ts +1 -1
- package/dist/packages/core/src/components/IconGridMenu.js +6 -3
- package/dist/packages/core/src/components/LandingHeader.js +6 -3
- package/dist/packages/core/src/components/Loader.d.ts +1 -1
- package/dist/packages/core/src/components/Loader.js +7 -2
- package/dist/packages/core/src/components/NoticeBox.d.ts +5 -0
- package/dist/packages/core/src/components/NoticeBox.js +3 -1
- package/dist/packages/core/src/components/Pagination.d.ts +2 -2
- package/dist/packages/core/src/components/Pagination.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +5 -1
- package/dist/packages/core/src/components/PassCard/PassCard.d.ts +10 -1
- package/dist/packages/core/src/components/PaymentPlan.d.ts +8 -1
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/ListPopup.d.ts +8 -1
- package/dist/packages/core/src/components/Popup/ListPopup.js +17 -2
- package/dist/packages/core/src/components/Popup/Popup.css +1 -1
- package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
- package/dist/packages/core/src/components/Popup/Popup.js +21 -2
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +3 -2
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.d.ts +4 -1
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +8 -3
- package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +7 -2
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +20 -3
- package/dist/packages/core/src/components/ProgressIndicator.d.ts +2 -5
- package/dist/packages/core/src/components/PromoBanner.js +23 -6
- package/dist/packages/core/src/components/RadioGroup.d.ts +3 -1
- package/dist/packages/core/src/components/RadioGroup.js +2 -2
- package/dist/packages/core/src/components/Report/ReportDocument.d.ts +4 -1
- package/dist/packages/core/src/components/Report/ReportDocument.js +4 -1
- package/dist/packages/core/src/components/Section.d.ts +7 -1
- package/dist/packages/core/src/components/Section.js +6 -2
- package/dist/packages/core/src/components/SideNav.css +16 -7
- package/dist/packages/core/src/components/SideNav.js +11 -1
- package/dist/packages/core/src/components/Table.d.ts +1 -1
- package/dist/packages/core/src/components/TextField.css +2 -1
- package/dist/packages/core/src/components/TextField.js +10 -2
- package/dist/packages/core/src/components/Toggle.js +4 -1
- package/dist/packages/core/src/components/Tooltip/DebugTooltip.d.ts +1 -6
- package/dist/packages/core/src/components/Tooltip/DebugTooltip.js +4 -7
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +11 -1
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +3 -3
- package/dist/packages/core/src/components/Training/TrainingBlock.d.ts +8 -3
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +10 -11
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +4 -4
- package/dist/packages/core/src/components/Training/trainingScenario.d.ts +6 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.js +6 -0
- package/dist/packages/core/src/components/Word/WordTableButton.d.ts +2 -2
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +1 -1
- package/dist/packages/core/src/components/Word/wordTableScoring.d.ts +1 -4
- package/dist/packages/core/src/components/Word/wordTableScoring.js +1 -4
- package/dist/packages/core/src/components/WordTable.d.ts +1 -1
- package/dist/packages/core/src/components/WordTable.js +3 -1
- package/dist/packages/core/src/components.d.ts +3 -0
- package/dist/packages/core/src/components.js +5 -0
- package/dist/packages/core/src/hooks/useDeviceManagement.js +11 -2
- package/dist/packages/core/src/hooks/useFontScale.js +3 -1
- package/dist/packages/core/src/hooks/useLucideIcon.d.ts +3 -4
- package/dist/packages/core/src/hooks/useLucideIcon.js +3 -4
- package/dist/packages/core/src/hooks/usePasswordChange.js +20 -7
- package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +7 -0
- package/dist/packages/core/src/hooks/useVisitAllPanel.js +8 -0
- package/dist/packages/core/src/hooks/useWordMeanings.js +3 -0
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +5 -0
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +23 -33
- package/dist/packages/core/src/i18n/locales/en.json +32 -5
- package/dist/packages/core/src/i18n/locales/ko.json +32 -5
- package/dist/packages/core/src/tokens.css +2 -1
- package/dist/packages/core/src/utils/CategoryData.js +2 -2
- package/dist/packages/core/src/utils/assets.js +7 -3
- package/dist/packages/core/src/utils/clipboard.js +16 -2
- package/dist/packages/core/src/utils/errorMessage.d.ts +2 -1
- package/dist/packages/core/src/utils/errorMessage.js +19 -2
- package/dist/packages/core/src/utils/getTrainingData.d.ts +8 -1
- package/dist/packages/core/src/utils/getTrainingData.js +8 -1
- package/dist/packages/core/src/utils/koreanSyllable.d.ts +15 -12
- package/dist/packages/core/src/utils/koreanSyllable.js +16 -13
- package/dist/packages/core/src/utils/lucideIconSet.d.ts +4 -3
- package/dist/packages/core/src/utils/lucideIconSet.js +4 -3
- package/dist/packages/core/src/utils/ttsResolver.d.ts +5 -0
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +3 -3
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +28 -7
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +4 -2
|
@@ -3,6 +3,7 @@ import { createElement } from 'react';
|
|
|
3
3
|
import useLucideIcon from '../hooks/useLucideIcon';
|
|
4
4
|
import useIcon from '../hooks/useIcon';
|
|
5
5
|
import { resolveAssetUrl } from '../utils';
|
|
6
|
+
import './Icon.css';
|
|
6
7
|
/** @public */
|
|
7
8
|
export default function Icon(props) {
|
|
8
9
|
const { icon, label, className, imgClassName, maskClassName, svgClassName, wrapperStyle, size } = props || {};
|
|
@@ -41,7 +42,12 @@ export default function Icon(props) {
|
|
|
41
42
|
if (iconToUse && iconToUse.endsWith('.svg')) {
|
|
42
43
|
if (mode === 'mask') {
|
|
43
44
|
const urlValue = blobUrl ? `url(${blobUrl})` : `url(${iconToUse})`;
|
|
44
|
-
|
|
45
|
+
// 크기는 기본 마스크 규칙(Icon.css)이 --icon-size 로 읽는다. width/height 를 직접 박으면
|
|
46
|
+
// 제 maskClassName 으로 크기를 정하는 Button·SideNav 를 덮어 버리므로 변수로만 건넨다.
|
|
47
|
+
const maskStyle = {
|
|
48
|
+
'--icon-url': urlValue,
|
|
49
|
+
...(typeof size === 'number' ? { '--icon-size': `${size}px` } : null),
|
|
50
|
+
};
|
|
45
51
|
return createWrapper(_jsx("span", { className: defaultMaskClass, style: maskStyle }), { 'aria-hidden': 'true' });
|
|
46
52
|
}
|
|
47
53
|
if (mode === 'img') {
|
|
@@ -35,13 +35,15 @@
|
|
|
35
35
|
box-shadow: var(--shadow-card);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/* 고른 단추 — 예전에는 `:disabled` 로 그렸다. 이제 disabled 를 쓰지 않으므로(IconGridMenu.tsx 참고)
|
|
39
|
+
같은 모양을 `--selected` 에 옮긴다. 값은 그대로. 단 `outline: none` 은 옮기지 않는다 —
|
|
40
|
+
이제 Tab 으로 닿으므로 포커스 표시가 보여야 한다. */
|
|
41
|
+
.icon-grid-menu__button.icon-grid-menu__button--selected {
|
|
39
42
|
cursor: default;
|
|
40
43
|
opacity: 0.95;
|
|
41
44
|
box-shadow: none;
|
|
42
45
|
border-color: var(--color-whiteLine);
|
|
43
46
|
pointer-events: none;
|
|
44
|
-
outline: none;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
.icon-grid-menu__button-icon {
|
|
@@ -10,7 +10,7 @@ export interface IconGridMenuCategory {
|
|
|
10
10
|
/** @public */
|
|
11
11
|
export interface IconGridMenuProps {
|
|
12
12
|
categories?: IconGridMenuCategory[];
|
|
13
|
-
/** 현재 선택된 카테고리 key (controlled) */
|
|
13
|
+
/** 현재 선택된 카테고리 key (controlled). `null` 이면 아무것도 고르지 않은 상태, 주지 않으면 첫 카테고리로 시작한다 */
|
|
14
14
|
selected?: string | null;
|
|
15
15
|
onSelect?: (key: string | null) => void;
|
|
16
16
|
/** 버튼 크기 — `medium`(기본, 68px) / `small`(48px) */
|
|
@@ -11,17 +11,20 @@ function IconGridMenuButton(props) {
|
|
|
11
11
|
// 이름표를 aria-labelledby 로 가리킨다 — label 이 문자열이 아니라 JSX 일 수도 있어
|
|
12
12
|
// aria-label 로 옮겨 적는 대신 화면에 이미 있는 그 요소를 그대로 이름으로 삼는다.
|
|
13
13
|
const labelId = useId();
|
|
14
|
-
return (_jsxs("div", { className: `icon-grid-menu__button-wrapper${keyClass}`, children: [_jsx("button", { className: `icon-grid-menu__button${isSelected ? ' icon-grid-menu__button--selected' : ''}`, "data-key": category?.key, onClick: isSelected ? undefined : onClick, "aria-pressed": isSelected, "aria-labelledby": labelId,
|
|
14
|
+
return (_jsxs("div", { className: `icon-grid-menu__button-wrapper${keyClass}`, children: [_jsx("button", { className: `icon-grid-menu__button${isSelected ? ' icon-grid-menu__button--selected' : ''}`, "data-key": category?.key, type: "button", onClick: isSelected ? undefined : onClick, "aria-pressed": isSelected, "aria-labelledby": labelId, children: _jsx(Icon, { icon: category.icon, label: category.label, className: "icon-grid-menu__button-icon", imgClassName: "icon-grid-menu__button-icon-image", maskClassName: "icon-grid-menu__button-icon-mask", svgClassName: "icon-grid-menu__button-icon-svg" }) }), _jsx("span", { id: labelId, className: "icon-grid-menu__button-label button-1", children: category.label })] }));
|
|
15
15
|
}
|
|
16
16
|
/** @public */
|
|
17
17
|
export default function IconGridMenu(props) {
|
|
18
18
|
const { categories = [], selected, onSelect, size = 'medium' } = props || {};
|
|
19
|
-
|
|
19
|
+
/* `null` 은 "고르지 않음" 이라는 제어 값이다 — 첫 카테고리로 갈음하지 않는다 */
|
|
20
|
+
const initial = selected !== undefined ? selected : ((categories && categories[0]?.key) ?? null);
|
|
20
21
|
const [selectedKey, setSelectedKey] = useState(initial ?? null);
|
|
21
22
|
// 외부 selected prop이 controlled로 전달되면 내부 상태 동기화하는 의도된 동기화다
|
|
22
23
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
23
24
|
useEffect(() => {
|
|
24
|
-
|
|
25
|
+
/* null 도 따른다 — 예전에는 null 을 걸러 버려, 부모가 selected={null} 로 선택을 풀어도
|
|
26
|
+
단추는 골라진 채로 남았다. */
|
|
27
|
+
if (selected !== undefined) {
|
|
25
28
|
setSelectedKey(selected);
|
|
26
29
|
}
|
|
27
30
|
}, [selected]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useId, useState } from 'react';
|
|
4
4
|
import './LandingHeader.css';
|
|
5
5
|
/**
|
|
6
6
|
* 랜딩/마케팅 페이지용 상단 고정 헤더.
|
|
@@ -19,6 +19,9 @@ import './LandingHeader.css';
|
|
|
19
19
|
*/
|
|
20
20
|
export default function LandingHeader({ logo, logoAlt = '', logoHref, links = [], actions, className, containerClassName, renderLink, menuLabel = { open: '메뉴 열기', close: '메뉴 닫기' }, }) {
|
|
21
21
|
const [open, setOpen] = useState(false);
|
|
22
|
+
// 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
|
|
23
|
+
// 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
|
|
24
|
+
const drawerId = useId();
|
|
22
25
|
const innerClass = containerClassName
|
|
23
26
|
? `${containerClassName} landing-header__inner`
|
|
24
27
|
: 'landing-header__inner';
|
|
@@ -34,7 +37,7 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
|
|
|
34
37
|
return logoHref
|
|
35
38
|
? _jsx("a", { href: logoHref, className: "landing-header__brand", children: brand })
|
|
36
39
|
: _jsx("div", { className: "landing-header__brand", children: brand });
|
|
37
|
-
})(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map(item => (_jsx("span", { children: link(item) }, item.href))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls":
|
|
40
|
+
})(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map(item => (_jsx("span", { children: link(item) }, item.href))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls": drawerId, "aria-label": open ? menuLabel.close : menuLabel.open, className: open
|
|
38
41
|
? 'landing-header__toggle landing-header__toggle--open'
|
|
39
|
-
: 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id:
|
|
42
|
+
: 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id: drawerId, hidden: !open, className: "landing-header__drawer", children: _jsx("div", { className: containerClassName ?? undefined, children: links.map(item => (_jsx("span", { className: "landing-header__drawer-item", children: link(item, () => setOpen(false)) }, item.href))) }) }))] }));
|
|
40
43
|
}
|
|
@@ -7,7 +7,7 @@ export interface LoaderProps {
|
|
|
7
7
|
size?: number | string;
|
|
8
8
|
/** 진행률(0–100). `variant='bar'`일 때 적용됩니다. */
|
|
9
9
|
progress?: number | null;
|
|
10
|
-
/** aria-label
|
|
10
|
+
/** 접근성 이름(aria-label). 화면에 글자로 나오지는 않는다. 안 주면 i18n 의 `loader.loading`(기본 'Loading'). */
|
|
11
11
|
label?: string;
|
|
12
12
|
}
|
|
13
13
|
/** @public */
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
2
4
|
import './Loader.css';
|
|
3
5
|
/** @public */
|
|
4
6
|
export default function Loader(props) {
|
|
7
|
+
const { t } = useTranslation();
|
|
5
8
|
const { variant = 'spinner', size = 24, progress = null, label } = props || {};
|
|
9
|
+
// 기본 이름이 영문 고정이라 한국어 화면에서도 낭독기가 'Loading' 으로 읽었다 — 사전을 거친다.
|
|
10
|
+
const name = label || t('loader.loading', 'Loading');
|
|
6
11
|
if (variant === 'bar') {
|
|
7
12
|
const pct = Math.max(0, Math.min(100, Number(progress) || 0));
|
|
8
|
-
return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label":
|
|
13
|
+
return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: { width: `${pct}%` } }) }));
|
|
9
14
|
}
|
|
10
15
|
const dim = typeof size === 'number' ? `${size}px` : size;
|
|
11
|
-
return (_jsx("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label":
|
|
16
|
+
return (_jsx("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] }) }));
|
|
12
17
|
}
|
|
@@ -4,9 +4,14 @@ import './NoticeBox.css';
|
|
|
4
4
|
export interface NoticeBoxProps {
|
|
5
5
|
title?: string;
|
|
6
6
|
lines?: string[];
|
|
7
|
+
/** 여러 안내를 한 상자에 차례로 싣는다. 주면 `title`·`lines`·`children` 대신 이것을 그린다. */
|
|
7
8
|
notices?: Array<{
|
|
8
9
|
title?: string;
|
|
9
10
|
lines?: string[];
|
|
11
|
+
/**
|
|
12
|
+
* 효과 없음 — 상자 모양은 바깥 `variant` 하나로만 정해진다.
|
|
13
|
+
* @deprecated 읽히지 않는 값이다. 항목마다 모양을 달리 그리는 스타일이 없다 — 모양이 다른 안내는 NoticeBox 를 따로 둔다.
|
|
14
|
+
*/
|
|
10
15
|
variant?: string;
|
|
11
16
|
children?: ReactNode;
|
|
12
17
|
}> | null;
|
|
@@ -15,5 +15,7 @@ export default function NoticeBox({ title = '', lines = [], notices = null, chil
|
|
|
15
15
|
return (_jsx("div", { className: baseClass, role: "note", children: notices.map((n, idx) => (_jsxs("div", { className: "notice-box__inner", children: [n.title ? _jsx("span", { className: "notice-box__title body-3", children: n.title }) : null, _jsx("div", { className: "notice-box__body", children: n.lines && n.lines.length ? (_jsx("div", { children: n.lines.map((l, i) => (_jsx("span", { className: "notice-box__line body-3", children: l }, i))) })) : (n.children ?? null) })] }, idx))) }));
|
|
16
16
|
}
|
|
17
17
|
const content = (title || lines.length) ? (_jsxs("div", { className: "notice-box__inner", children: [title ? _jsx("span", { className: "notice-box__title body-1", children: title }) : null, _jsx("div", { className: "notice-box__body", children: lines.length ? (_jsx("ul", { children: lines.map((l, i) => (_jsx("li", { className: "notice-box__line", children: l }, i))) })) : children })] })) : (_jsx("div", { className: "notice-box__inner", children: children }));
|
|
18
|
-
return (_jsxs("div", { className: baseClass, role: "note", children: [content, dismissible && (_jsx("button", {
|
|
18
|
+
return (_jsxs("div", { className: baseClass, role: "note", children: [content, dismissible && (_jsx("button", {
|
|
19
|
+
// type="button" — 없으면 폼 안에서 닫기를 누르는 순간 폼이 제출된다
|
|
20
|
+
type: "button", className: "notice-box__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }))] }));
|
|
19
21
|
}
|
|
@@ -14,8 +14,8 @@ export interface PaginationProps {
|
|
|
14
14
|
className?: string;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* 페이지네이션 컨트롤 (‹ 현재/전체 ›).
|
|
18
|
-
*
|
|
17
|
+
* 페이지네이션 컨트롤 (‹ 현재/전체 ›). 표 아래에 두고 `page`·`totalPages` 를 넘기면
|
|
18
|
+
* 이전·다음 단추가 `onChange` 로 1~`totalPages` 안의 쪽 번호를 돌려준다. 끝에서는 그쪽 단추가 꺼진다.
|
|
19
19
|
*
|
|
20
20
|
* @public
|
|
21
21
|
*/
|
|
@@ -5,8 +5,8 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import Button from './Button';
|
|
6
6
|
import './Pagination.css';
|
|
7
7
|
/**
|
|
8
|
-
* 페이지네이션 컨트롤 (‹ 현재/전체 ›).
|
|
9
|
-
*
|
|
8
|
+
* 페이지네이션 컨트롤 (‹ 현재/전체 ›). 표 아래에 두고 `page`·`totalPages` 를 넘기면
|
|
9
|
+
* 이전·다음 단추가 `onChange` 로 1~`totalPages` 안의 쪽 번호를 돌려준다. 끝에서는 그쪽 단추가 꺼진다.
|
|
10
10
|
*
|
|
11
11
|
* @public
|
|
12
12
|
*/
|
|
@@ -54,7 +54,11 @@ export function PanelRow(props) {
|
|
|
54
54
|
const p = props;
|
|
55
55
|
return (_jsx("div", { className: "panel-row panel-row--block", children: p.children }));
|
|
56
56
|
}
|
|
57
|
-
const { variant, label, sub, onClick, required, error, align = 'horizontal', useFullWidth = false, icon } = props;
|
|
57
|
+
const { variant, label, sub, onClick: baseOnClick, required, error, align = 'horizontal', useFullWidth = false, icon } = props;
|
|
58
|
+
// button 행의 onClick 은 **단추의 것**이다(PanelRowButton.onClick). 행에도 같이 걸면
|
|
59
|
+
// 단추를 누른 클릭이 행으로 올라와 한 번 누름에 두 번 불렸다. 행 전체 클릭은 button 이 아닌
|
|
60
|
+
// variant 에서만 받는다.
|
|
61
|
+
const onClick = variant === 'button' ? undefined : baseOnClick;
|
|
58
62
|
let right = null;
|
|
59
63
|
// toggle 행만 왼쪽 라벨(panel-row__label)을 <label htmlFor>로 렌더해 스위치와 프로그래밍적으로
|
|
60
64
|
// 연결한다 — 그 전엔 시각적으로만 옆에 붙어있어 라벨 텍스트를 클릭해도 토글이 안 되고
|
|
@@ -16,7 +16,12 @@ export interface PassCardProps {
|
|
|
16
16
|
/** 기간 토큰(예: '30일'). 미지정 시 product에서 도출. */
|
|
17
17
|
plan?: string;
|
|
18
18
|
type?: PassCardType;
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated 읽지 않는다 — 카드는 시작일을 그리지 않고, 남은 기간은 `endDate`(또는 `remainingSeconds`)로만 센다.
|
|
21
|
+
* 넘겨도 화면은 같다.
|
|
22
|
+
*/
|
|
19
23
|
startDate?: string;
|
|
24
|
+
/** 끝나는 날(YYYY-MM-DD). `state` 가 active·expired 이고 `remainingSeconds` 가 없으면 이 날까지 남은 날수로 사용량을 센다. */
|
|
20
25
|
endDate?: string;
|
|
21
26
|
/**
|
|
22
27
|
* 전환으로 일시정지되어 남은 시간(초)이 알려진 경우에만 지정.
|
|
@@ -26,8 +31,12 @@ export interface PassCardProps {
|
|
|
26
31
|
*/
|
|
27
32
|
remainingSeconds?: number;
|
|
28
33
|
state?: PassCardState;
|
|
34
|
+
/** @deprecated 읽지 않는다 — 대기 순서는 카드에 그리지 않는다. 넘겨도 화면은 같다. */
|
|
29
35
|
queueIndex?: number;
|
|
30
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* 배경 컴포넌트. 안 주면 종류·기간·라벨로 고른다 — 말귀(`speechear`)는 늘 PassCardBgTwoCircle,
|
|
38
|
+
* 말귀 매니저(`manager`)는 1년이면 PassCardBgTalk, 라벨이 오픈특가면 PassCardBgSoundMoveV1, 그 밖에는 PassCardBgSoundMove.
|
|
39
|
+
*/
|
|
31
40
|
bg?: ReactNode;
|
|
32
41
|
/** true면 배경만 표시하고 콘텐츠(로고·플랜·카피라이트)를 숨김 */
|
|
33
42
|
bgOnly?: boolean;
|
|
@@ -26,8 +26,15 @@ export interface PaymentPlanTier {
|
|
|
26
26
|
export interface PaymentPlanData {
|
|
27
27
|
id: string | number;
|
|
28
28
|
title?: string;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* 서비스명(말귀/말귀매니저). 이 컴포넌트는 읽지 않는다 — 카드 로고는 `group` 으로 고른다.
|
|
31
|
+
* 부르는 쪽이 상품 목록을 나누거나 표기할 때 쓰도록 자리만 둔 칸이다.
|
|
32
|
+
*/
|
|
30
33
|
service?: string;
|
|
34
|
+
/**
|
|
35
|
+
* 상품 묶음. `'org'` 면 이용권 카드를 말귀 매니저 모양으로, 그 밖(`'personal'` 등)이면 말귀 모양으로 그린다.
|
|
36
|
+
*/
|
|
37
|
+
group?: 'personal' | 'org' | (string & {});
|
|
31
38
|
/** 플랜 티어명(스탠다드/라이트/프로 등). 카드 헤드라인용. */
|
|
32
39
|
tier?: string;
|
|
33
40
|
subtitle?: string;
|
|
@@ -59,7 +59,7 @@ export interface ConfirmPopupProps {
|
|
|
59
59
|
onClose: () => void;
|
|
60
60
|
/** portal 없이 부모 컨테이너에 제자리 렌더 (목업/데모용) */
|
|
61
61
|
inline?: boolean;
|
|
62
|
-
/** 패널 최대 가로폭. 확인창 기본은 'small'(
|
|
62
|
+
/** 패널 최대 가로폭. 확인창 기본은 'small'(480px) */
|
|
63
63
|
size?: 'small' | 'medium' | 'large';
|
|
64
64
|
className?: string;
|
|
65
65
|
}
|
|
@@ -40,7 +40,14 @@ export interface PopupListProps<T extends {
|
|
|
40
40
|
selectedId?: string | number;
|
|
41
41
|
/** 현재 선택된 아이템 id Set — 다중 선택 강조 (`selectedId`보다 우선) */
|
|
42
42
|
selectedIds?: Set<string | number>;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* 효과 없음 — ListPopup 은 행을 골라도 **스스로 닫지 않는다**(닫기는 늘 `onSelect`·`onClose` 를
|
|
45
|
+
* 받은 쪽이 정한다). 그래서 이 값은 읽지 않는다.
|
|
46
|
+
*
|
|
47
|
+
* @deprecated 읽히지 않는 값이다. 예전 JSDoc 은 "기본 false = 고르면 닫힘" 이라 했지만 그렇게
|
|
48
|
+
* 동작한 적이 없고, 그 뜻대로 고치면 행 클릭이 아무것도 안 하는 화면(행 안 단추로만 보내는 창 등)이
|
|
49
|
+
* 누르는 순간 닫힌다. 고른 뒤 닫으려면 `onSelect` 안에서 직접 닫는다.
|
|
50
|
+
*/
|
|
44
51
|
keepOpenOnSelect?: boolean;
|
|
45
52
|
/** 행 선택 콜백 */
|
|
46
53
|
onSelect: (item: T) => void;
|
|
@@ -63,12 +63,27 @@ export default function ListPopup(props) {
|
|
|
63
63
|
}, [sortKey]);
|
|
64
64
|
// countLabel("총 {n}명")을 Toolbar count의 prefix/suffix로 분해
|
|
65
65
|
const countParts = useMemo(() => {
|
|
66
|
+
/* 함수 꼴은 예전에 통째로 무시되어 기본 "총 N개" 가 떴다(JSDoc 은 받는다고 했다).
|
|
67
|
+
Toolbar 는 숫자를 강조 칸에 따로 그리므로, 돌려받은 문자열에서 개수가 **마지막으로**
|
|
68
|
+
나오는 자리를 갈라 앞·뒤로 넘긴다 — 앞+숫자+뒤가 언제나 원래 문자열과 같아서
|
|
69
|
+
글은 틀리지 않는다(숫자가 여럿이면 강조 자리만 어긋날 수 있다). 문자열이 아니거나
|
|
70
|
+
개수가 안 들어 있으면 가를 수 없으니 받은 것을 그대로 그린다. */
|
|
71
|
+
if (typeof countLabel === 'function') {
|
|
72
|
+
const n = filtered.length;
|
|
73
|
+
const label = countLabel(n);
|
|
74
|
+
if (typeof label === 'string') {
|
|
75
|
+
const at = label.lastIndexOf(String(n));
|
|
76
|
+
if (at >= 0)
|
|
77
|
+
return { prefix: label.slice(0, at), suffix: label.slice(at + String(n).length) };
|
|
78
|
+
}
|
|
79
|
+
return { label };
|
|
80
|
+
}
|
|
66
81
|
if (typeof countLabel === 'string' && countLabel.includes('{n}')) {
|
|
67
82
|
const [prefix, suffix] = countLabel.split('{n}');
|
|
68
83
|
return { prefix, suffix };
|
|
69
84
|
}
|
|
70
85
|
return { prefix: t('common.totalCountPrefix', '총 '), suffix: t('common.totalCountSuffix', '개') };
|
|
71
|
-
}, [countLabel, t]);
|
|
86
|
+
}, [countLabel, filtered.length, t]);
|
|
72
87
|
const tableFields = useMemo(() => columns.map(col => ({
|
|
73
88
|
label: col.label,
|
|
74
89
|
onClick: col.sortable !== false ? () => handleSort(col.key) : undefined,
|
|
@@ -76,7 +91,7 @@ export default function ListPopup(props) {
|
|
|
76
91
|
sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
|
|
77
92
|
})), [columns, sortKey, sortAsc, handleSort]);
|
|
78
93
|
const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
|
|
79
|
-
return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, _jsx(Toolbar, { count: { 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, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
|
|
94
|
+
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, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
|
|
80
95
|
const isSelected = selectedIds != null
|
|
81
96
|
? (it.id != null && selectedIds.has(it.id))
|
|
82
97
|
: (selectedId != null && it.id === selectedId);
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
display: flex;
|
|
31
31
|
flex-direction: column;
|
|
32
32
|
}
|
|
33
|
-
/* size별 최대 가로폭 — 미지정 시 위의 기본(820px) 유지.
|
|
33
|
+
/* size별 최대 가로폭 — 미지정 시 위의 기본(820px) 유지. small 480 · medium 680 · large 1080 */
|
|
34
34
|
.popup-panel--small { max-width: 480px; }
|
|
35
35
|
.popup-panel--medium { max-width: 680px; }
|
|
36
36
|
.popup-panel--large { max-width: 1080px; }
|
|
@@ -46,6 +46,14 @@ export interface PopupProps {
|
|
|
46
46
|
* 미지정이면 기존 동작과 완전히 동일하다.
|
|
47
47
|
*/
|
|
48
48
|
beforeClose?: () => boolean;
|
|
49
|
+
/**
|
|
50
|
+
* 어두운 바탕(패널 바깥)을 누르면 닫는다. 기본 true.
|
|
51
|
+
*
|
|
52
|
+
* 닫기는 `closeWithAnimation` 을 그대로 거치므로 `closeDisabled`·`beforeClose` 가
|
|
53
|
+
* 똑같이 막는다. ESC 와 같은 조건(`inline` 이 아니고 `onClose` 가 있을 때)에서만 건다.
|
|
54
|
+
* 바깥을 잘못 눌러 입력이 날아가면 안 되는 창은 `beforeClose` 로 묻거나 이것을 끈다.
|
|
55
|
+
*/
|
|
56
|
+
closeOnBackdrop?: boolean;
|
|
49
57
|
className?: string;
|
|
50
58
|
/**
|
|
51
59
|
* 다이얼로그 접근명 — `title`이 없는 팝업(useTitle=false 등)에서 쓴다.
|
|
@@ -61,7 +69,7 @@ export interface PopupProps {
|
|
|
61
69
|
* 패널 최대 가로폭 제한. 미지정 시 기본(820px) 유지.
|
|
62
70
|
* - `small` 480px (확인/알림류)
|
|
63
71
|
* - `medium` 680px (입력 폼)
|
|
64
|
-
* - `large`
|
|
72
|
+
* - `large` 1080px (리스트/다중 콘텐츠)
|
|
65
73
|
* 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
|
|
66
74
|
*/
|
|
67
75
|
size?: 'small' | 'medium' | 'large';
|
|
@@ -13,7 +13,7 @@ const ANIM_MS = 260;
|
|
|
13
13
|
/** @public */
|
|
14
14
|
export default function Popup(props) {
|
|
15
15
|
const { t } = useTranslation();
|
|
16
|
-
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, className = "", ariaLabel, useTitle = true, inline = false, size, resetScrollKey } = props || {};
|
|
16
|
+
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, resetScrollKey } = props || {};
|
|
17
17
|
const [closing, setClosing] = useState(false);
|
|
18
18
|
// 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
|
|
19
19
|
// 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
|
|
@@ -128,7 +128,26 @@ export default function Popup(props) {
|
|
|
128
128
|
return;
|
|
129
129
|
return pushEsc(() => closeRef.current());
|
|
130
130
|
}, [escEnabled]);
|
|
131
|
-
|
|
131
|
+
/* 바탕 누르기 — **누른 곳과 뗀 곳이 둘 다 바탕일 때만** 닫는다.
|
|
132
|
+
click 의 target 만 보면 패널 안에서 글자를 긁어 고르다 바깥에서 놓았을 때도
|
|
133
|
+
바탕에서 click 이 떨어져 창이 닫힌다(브라우저는 공통 조상에 click 을 쏜다).
|
|
134
|
+
그래서 누를 때 자리를 기억해 둔다.
|
|
135
|
+
`e.target === e.currentTarget` 은 팝업 위 팝업도 막는다 — 위 창이 아래 창의
|
|
136
|
+
React 자식으로 그려지면 합성 이벤트가 **DOM 이 아니라 React 나무를 따라**
|
|
137
|
+
아래 창의 바탕 핸들러까지 올라오는데, 그때 target 은 위 창의 바탕이라
|
|
138
|
+
아래 창은 제 것이 아니라고 보고 넘긴다. */
|
|
139
|
+
const backdropEnabled = escEnabled && closeOnBackdrop;
|
|
140
|
+
const pressedOnBackdropRef = useRef(false);
|
|
141
|
+
const onBackdropPointerDown = (e) => {
|
|
142
|
+
pressedOnBackdropRef.current = e.target === e.currentTarget;
|
|
143
|
+
};
|
|
144
|
+
const onBackdropClick = (e) => {
|
|
145
|
+
const pressed = pressedOnBackdropRef.current;
|
|
146
|
+
pressedOnBackdropRef.current = false;
|
|
147
|
+
if (pressed && e.target === e.currentTarget)
|
|
148
|
+
closeWithAnimation();
|
|
149
|
+
};
|
|
150
|
+
const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
|
|
132
151
|
if (inline || typeof document === 'undefined')
|
|
133
152
|
return el;
|
|
134
153
|
return createPortal(el, document.body);
|
|
@@ -81,14 +81,15 @@ export default function TrainingResultPopup(props) {
|
|
|
81
81
|
const list = useMemo(() => Array.isArray(results) ? results : [], [results]);
|
|
82
82
|
const rows = useMemo(() => list.map((it) => {
|
|
83
83
|
const itr = it ?? {};
|
|
84
|
-
|
|
84
|
+
// 문제 글이 비었을 때의 자리표시도 번역을 거친다 — 영어 화면에 한국어가 박혀 나왔다
|
|
85
|
+
const problem = resolveProblem(itr) || t('training.result.noContent', '(내용없음)');
|
|
85
86
|
const { variant, label } = getJudge(itr);
|
|
86
87
|
const corrects = Array.isArray(itr.corrects) ? itr.corrects : [];
|
|
87
88
|
const answer = corrects.length ? corrects.join(', ') : (itr.answer != null && itr.answer !== '' ? String(itr.answer) : null);
|
|
88
89
|
const selectedText = typeof itr.selectedText === 'string' ? itr.selectedText : null;
|
|
89
90
|
const isCorrect = resolveDecision(itr) === true;
|
|
90
91
|
return { problem, variant, label, answer, selectedText, isCorrect };
|
|
91
|
-
}), [list, getJudge]);
|
|
92
|
+
}), [list, getJudge, t]);
|
|
92
93
|
const hasAnswerCols = useMemo(() => rows.some(r => r.answer !== null || r.selectedText !== null), [rows]);
|
|
93
94
|
// 스크롤은 Popup의 .popup-content 하나만 담당한다 — 예전엔 결과 카드가 자체 스크롤
|
|
94
95
|
// 래퍼(.trp-result__scroll)를 하나 더 두어 이중 스크롤이 됐고, 안쪽이 overscroll:contain으로
|
|
@@ -4,9 +4,12 @@ interface WordDataUpdatePopupProps {
|
|
|
4
4
|
title: string;
|
|
5
5
|
progress: WordDataProgress;
|
|
6
6
|
onDone: () => void;
|
|
7
|
+
/** 오류 때 '다시 시도' 단추가 부른다. 없으면 그 자리에 '닫기' 단추가 선다. */
|
|
7
8
|
onRetry?: () => void;
|
|
9
|
+
/** 오류인데 `onRetry` 가 없을 때 '닫기' 단추가 부른다. 없으면 `onDone` 을 부른다. */
|
|
10
|
+
onClose?: () => void;
|
|
8
11
|
inline?: boolean;
|
|
9
12
|
}
|
|
10
13
|
/** @public */
|
|
11
|
-
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, inline }: WordDataUpdatePopupProps): import("react").JSX.Element;
|
|
14
|
+
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, onClose, inline }: WordDataUpdatePopupProps): import("react").JSX.Element;
|
|
12
15
|
export {};
|
|
@@ -37,7 +37,7 @@ function PhaseCard({ icon, label, state, pct, subText }) {
|
|
|
37
37
|
: (subText || pct !== undefined) && (_jsxs("span", { className: "wdup-phase-card__sub body-3", children: [subText, subText && pct !== undefined ? ` · ${pct}%` : pct !== undefined ? `${pct}%` : ''] }))] }), !isPending && (_jsx("div", { className: "wdup-bar", children: _jsx("div", { className: `wdup-bar__fill${isDone ? ' wdup-bar__fill--done' : isError ? ' wdup-bar__fill--error' : ''}${state === 'active' && pct === undefined ? ' wdup-bar__fill--indeterminate' : ''}`, style: { width: pct !== undefined || isDone || isError ? `${isError ? 100 : barPct}%` : undefined } }) }))] })] }));
|
|
38
38
|
}
|
|
39
39
|
/** @public */
|
|
40
|
-
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, inline }) {
|
|
40
|
+
export default function WordDataUpdatePopup({ title, progress, onDone, onRetry, onClose, inline }) {
|
|
41
41
|
const { t } = useTranslation();
|
|
42
42
|
const { phase, error, wordCount, imagePage, imageDone, imageTotal, pairPercent } = progress;
|
|
43
43
|
const order = phaseOrder(phase);
|
|
@@ -67,7 +67,12 @@ export default function WordDataUpdatePopup({ title, progress, onDone, onRetry,
|
|
|
67
67
|
return t('wordData.imageCount', { done: formatCount(imageDone), total: formatCount(imageTotal) });
|
|
68
68
|
return undefined;
|
|
69
69
|
}, [imgState, phase, imagePage, imageTotal, imageDone, t]);
|
|
70
|
-
return (_jsx(Popup, { inline: inline, className: "popup--word-data-update", title: title, footer:
|
|
70
|
+
return (_jsx(Popup, { inline: inline, className: "popup--word-data-update", title: title, footer:
|
|
71
|
+
/* 오류인데 다시 시도할 곳이 없으면 '다시 시도' 가 아무 일도 안 하고, 이 팝업은 X 도
|
|
72
|
+
Esc 도 없어 **빠져나갈 길이 없었다**. 그때만 닫기 단추로 바꾼다. */
|
|
73
|
+
isError && onRetry
|
|
71
74
|
? _jsx(Button, { size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
|
|
72
|
-
:
|
|
75
|
+
: isError
|
|
76
|
+
? _jsx(Button, { size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
|
|
77
|
+
: _jsx(Button, { size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
|
|
73
78
|
}
|
|
@@ -26,5 +26,10 @@ export declare function visitItemKey(v: {
|
|
|
26
26
|
hour: string;
|
|
27
27
|
minute: string;
|
|
28
28
|
}): string;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
|
|
31
|
+
*
|
|
32
|
+
* 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
|
|
33
|
+
* 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatDuration(sec: number | null | undefined, durationStr?: (m: number, s: number) => string): string;
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* customerDetailUtils.ts
|
|
3
3
|
* CustomerDetailPopup의 순수 표시/포맷 유틸 (상태 비의존).
|
|
4
4
|
*/
|
|
5
|
+
import i18n from 'i18next';
|
|
5
6
|
import { getTodayKst } from '../../utils/date';
|
|
7
|
+
/** i18n 이 아직 안 섰으면(초기화 전 `i18n.t` 는 undefined) 한국어 기본값을 직접 채운다. */
|
|
8
|
+
function 번역됐거나(s, 기본, vars) {
|
|
9
|
+
return typeof s === 'string' && s ? s : 기본.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k]));
|
|
10
|
+
}
|
|
6
11
|
export function getDayOptions(year, month, daySuffix) {
|
|
7
12
|
const days = new Date(Number(year), Number(month), 0).getDate();
|
|
8
13
|
return Array.from({ length: days }, (_, i) => {
|
|
@@ -65,11 +70,23 @@ export function formatId(id) {
|
|
|
65
70
|
export function visitItemKey(v) {
|
|
66
71
|
return `${v.year}-${v.month}-${v.day}T${v.hour}:${v.minute}`;
|
|
67
72
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
73
|
+
/**
|
|
74
|
+
* 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
|
|
75
|
+
*
|
|
76
|
+
* 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
|
|
77
|
+
* 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
|
|
78
|
+
*/
|
|
79
|
+
export function formatDuration(sec, durationStr) {
|
|
70
80
|
if (!sec || sec <= 0)
|
|
71
81
|
return '-';
|
|
72
82
|
const m = Math.floor(sec / 60);
|
|
73
83
|
const s = sec % 60;
|
|
74
|
-
|
|
84
|
+
if (durationStr)
|
|
85
|
+
return durationStr(m, s);
|
|
86
|
+
// 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
|
|
87
|
+
if (m > 0 && s > 0)
|
|
88
|
+
return 번역됐거나(i18n.t('customers.duration.minSec', { m, s, defaultValue: '{{m}}분 {{s}}초' }), '{{m}}분 {{s}}초', { m, s });
|
|
89
|
+
if (m > 0)
|
|
90
|
+
return 번역됐거나(i18n.t('customers.duration.min', { m, defaultValue: '{{m}}분' }), '{{m}}분', { m });
|
|
91
|
+
return 번역됐거나(i18n.t('customers.duration.sec', { s, defaultValue: '{{s}}초' }), '{{s}}초', { s });
|
|
75
92
|
}
|
|
@@ -32,11 +32,8 @@ export interface ProgressIndicatorProps {
|
|
|
32
32
|
*/
|
|
33
33
|
announce?: boolean;
|
|
34
34
|
/**
|
|
35
|
-
* 이름표를 직접 준다. 안 주면 i18n 의 `progress.steps` 로
|
|
36
|
-
*
|
|
37
|
-
* i18n 을 쓰지 않는 앱에서는 그 값이 한글 기본값으로 떨어진다 —
|
|
38
|
-
* 화면은 영문인데 이름표만 한글이 되는 자리가 실제로 있었다
|
|
39
|
-
* (sound-localization-system 의 병원용 빌드).
|
|
35
|
+
* 이름표를 직접 준다. 안 주면 i18n 의 `progress.steps`('진행 단계 N / M')로 짓는다 —
|
|
36
|
+
* i18n 사전을 싣지 않는 앱은 이름표가 한글 기본값으로 떨어지므로 번역한 글을 여기 넘긴다.
|
|
40
37
|
*/
|
|
41
38
|
ariaLabel?: string;
|
|
42
39
|
}
|
|
@@ -17,17 +17,34 @@ export default function PromoBanner({ title, description, actionLabel, onAction,
|
|
|
17
17
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
|
18
18
|
// PromoBannerBg*가 useLayoutEffect(child)에서 --promo-banner-bg-title/description을
|
|
19
19
|
// 주입한 뒤, 이 useLayoutEffect(parent)에서 override → 항상 마지막에 적용됨.
|
|
20
|
-
// ⚠️ prop이 undefined일 때는
|
|
20
|
+
// ⚠️ prop이 undefined일 때는 removeProperty 하지 않는다 — 배경이 주입한 값을
|
|
21
21
|
// 지워버리기 때문. undefined = "override 안 함"(PassCard의 textColor와 동일 패턴).
|
|
22
|
+
// 다만 **줬던 값을 거둘 때는** 배경이 정한 값(레지스트리의 theme)으로 되돌린다 — 예전에는
|
|
23
|
+
// undefined 로 바꿔도 마지막으로 준 색이 CSS 변수에 그대로 남았다.
|
|
24
|
+
// 배경을 갈면 새 배경이 제 값을 다시 주입하므로 resolvedBg 가 바뀔 때도 다시 덮는다.
|
|
25
|
+
const overriddenRef = useRef({ title: false, description: false });
|
|
22
26
|
useLayoutEffect(() => {
|
|
23
27
|
const el = rootRef.current;
|
|
24
28
|
if (!el)
|
|
25
29
|
return;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const theme = getPromoBannerBgTheme(el);
|
|
31
|
+
const apply = (key, name, value) => {
|
|
32
|
+
if (value !== undefined) {
|
|
33
|
+
el.style.setProperty(name, value);
|
|
34
|
+
overriddenRef.current[key] = true;
|
|
35
|
+
}
|
|
36
|
+
else if (overriddenRef.current[key]) {
|
|
37
|
+
const fallback = theme?.[key];
|
|
38
|
+
if (fallback)
|
|
39
|
+
el.style.setProperty(name, fallback);
|
|
40
|
+
else
|
|
41
|
+
el.style.removeProperty(name);
|
|
42
|
+
overriddenRef.current[key] = false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
apply('title', '--promo-banner-bg-title', titleColor);
|
|
46
|
+
apply('description', '--promo-banner-bg-description', descriptionColor);
|
|
47
|
+
}, [titleColor, descriptionColor, resolvedBg]);
|
|
31
48
|
// 배경(Bg)이 자신의 useLayoutEffect에서 레지스트리에 theme을 주입한 뒤 읽는다(PassCard의
|
|
32
49
|
// logoColor와 동일 패턴) — 배경마다 다른 기본 버튼 variant를 PromoBannerBg* 파일이 직접 갖는다.
|
|
33
50
|
useLayoutEffect(() => {
|
|
@@ -16,6 +16,8 @@ export interface RadioGroupProps {
|
|
|
16
16
|
disabled?: boolean;
|
|
17
17
|
direction?: RadioGroupDirection;
|
|
18
18
|
className?: string;
|
|
19
|
+
/** 묶음의 이름 — 낭독기가 "무엇을 고르는 라디오 묶음" 인지 읽는다. 보이는 제목이 따로 있어도 이어지지 않으면 준다. */
|
|
20
|
+
ariaLabel?: string;
|
|
19
21
|
}
|
|
20
22
|
/** @public */
|
|
21
|
-
export default function RadioGroup({ options, value, onChange, name, disabled, direction, className, }: RadioGroupProps): import("react").JSX.Element;
|
|
23
|
+
export default function RadioGroup({ options, value, onChange, name, disabled, direction, className, ariaLabel, }: RadioGroupProps): import("react").JSX.Element;
|