@ihabdevteam/core 0.134.0 → 0.136.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/dist/components.cjs.js +177 -177
- 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.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +19 -3
- package/dist/packages/core/src/components/Alert.js +1 -1
- package/dist/packages/core/src/components/BarChart.js +3 -1
- package/dist/packages/core/src/components/Button.css +5 -0
- package/dist/packages/core/src/components/Button.js +3 -3
- package/dist/packages/core/src/components/Checkbox.js +10 -2
- package/dist/packages/core/src/components/CodeBox.js +2 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +2 -0
- package/dist/packages/core/src/components/IconGridMenu.js +25 -22
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
- package/dist/packages/core/src/components/Popup/ListPopup.js +10 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
- package/dist/packages/core/src/components/Popup/Popup.css +9 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
- package/dist/packages/core/src/components/Popup/Popup.js +2 -2
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
- package/dist/packages/core/src/components/ProgressBar.js +5 -2
- package/dist/packages/core/src/components/ProgressIndicator.js +6 -1
- package/dist/packages/core/src/components/Rating.js +3 -1
- package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
- package/dist/packages/core/src/components/RingChart.js +14 -3
- package/dist/packages/core/src/components/SideNav.js +6 -3
- package/dist/packages/core/src/components/SplashLogo.js +1 -2
- package/dist/packages/core/src/components/StatChart.js +1 -1
- package/dist/packages/core/src/components/Table.js +15 -5
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
- package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +8 -4
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +4 -3
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
- package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.js +7 -3
- package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
- package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
- package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
- package/dist/packages/core/src/components/barChartData.d.ts +8 -0
- package/dist/packages/core/src/components/barChartData.js +21 -0
- package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
- package/dist/packages/core/src/components/useBarChartControl.js +5 -3
- package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
- package/dist/packages/core/src/context/I18nProvider.js +12 -3
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
- package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
- package/dist/packages/core/src/hooks/useEditableList.js +20 -1
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
- package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
- package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
- package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
- package/dist/packages/core/src/i18n/locales/en.json +3 -1
- package/dist/packages/core/src/i18n/locales/ko.json +3 -1
- package/dist/packages/core/src/utils/date.js +26 -5
- package/dist/packages/core/src/utils/getTrainingData.js +11 -4
- package/dist/packages/core/src/utils/imageConfig.js +6 -3
- package/dist/packages/core/src/utils/speak.js +23 -3
- package/dist/packages/core/src/utils/textSort.js +12 -1
- package/dist/packages/core/src/utils/ttsConfig.js +10 -8
- package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +11 -4
- 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 +1 -1
|
@@ -773,6 +773,12 @@ export declare interface ButtonProps {
|
|
|
773
773
|
title?: string;
|
|
774
774
|
items: string[];
|
|
775
775
|
} | null;
|
|
776
|
+
/**
|
|
777
|
+
* 누르는 단추가 아니라 **툴팁을 보여 주는 칩** 이다 — 커서를 손가락이 아닌 기본 화살표로 둔다.
|
|
778
|
+
* 모양(바탕·hover)과 포커스·툴팁은 그대로다. `onClick` 없이 `showTooltipInfo`·`tooltipDescription` 만 다는
|
|
779
|
+
* 자리(점수 칩 등)에 쓴다. 막힌 단추(`disabled`)와는 다르다 — 흐려지지 않는다.
|
|
780
|
+
*/
|
|
781
|
+
tooltipOnly?: boolean;
|
|
776
782
|
iconSize?: 'small' | 'medium' | 'large';
|
|
777
783
|
iconColor?: string;
|
|
778
784
|
/**
|
|
@@ -1369,7 +1375,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1369
1375
|
*
|
|
1370
1376
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1371
1377
|
*/
|
|
1372
|
-
export declare const CORE_VERSION = "0.
|
|
1378
|
+
export declare const CORE_VERSION = "0.136.0";
|
|
1373
1379
|
|
|
1374
1380
|
/** @public */
|
|
1375
1381
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -4150,9 +4156,17 @@ export declare interface PopupProps {
|
|
|
4150
4156
|
* - `small` 480px (확인/알림류)
|
|
4151
4157
|
* - `medium` 680px (입력 폼)
|
|
4152
4158
|
* - `large` 1080px (리스트/다중 콘텐츠)
|
|
4159
|
+
* - `xlarge` 1320px, 폭은 화면 - 80px(좌우 40px) — 훈련 카드 만들기처럼 화면을 넓게 쓰는 창
|
|
4160
|
+
* - `fit` 내용 폭에 맞춘다 — 최소 480px·최대 1140px(둘 다 화면 폭 - 40px 을 넘지 않는다).
|
|
4161
|
+
* 한 줄에 카드가 몇 장 들어가느냐로 폭이 정해지는 목록 창에 쓴다.
|
|
4153
4162
|
* 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
|
|
4154
4163
|
*/
|
|
4155
|
-
size?: 'small' | 'medium' | 'large';
|
|
4164
|
+
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fit';
|
|
4165
|
+
/**
|
|
4166
|
+
* 쌓이는 층. 기본 `modal`(`--z-modal`) 은 훈련 페이지 팝업(`--z-page-popup`) 위에 뜬다.
|
|
4167
|
+
* `belowPagePopup` 은 그 **바로 아래** 에 깐다 — 이 창에서 연 훈련 팝업이 이 창을 덮어야 할 때(실행 창 등).
|
|
4168
|
+
*/
|
|
4169
|
+
layer?: 'modal' | 'belowPagePopup';
|
|
4156
4170
|
/**
|
|
4157
4171
|
* 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
|
|
4158
4172
|
*
|
|
@@ -6863,8 +6877,10 @@ export declare interface TrainingBlockButtonProps {
|
|
|
6863
6877
|
* - `small`: 120×120px 축소 카드 (행 펼치기 영역 등 좁은 공간용)
|
|
6864
6878
|
* - `list`: 너비 200px · 최소 높이 68px 가로형 항목
|
|
6865
6879
|
* - `compact`: 툴팁 등 좁은 공간용 소형 리스트 항목 (전체 너비 × 높이 40px, 작은 아이콘)
|
|
6880
|
+
* - `row`: 전체 너비 가로 행 — [아이콘][유형·제목][문항수]. 미리 듣기 단추는 없다.
|
|
6881
|
+
* 좁은 화면에서 정사각형 카드 대신 쓴다(카드는 폭이 줄면 내용이 눌린다). 유형 줄은 `showTypeLabel` 일 때.
|
|
6866
6882
|
*/
|
|
6867
|
-
size?: 'square' | 'small' | 'list' | 'compact';
|
|
6883
|
+
size?: 'square' | 'small' | 'list' | 'compact' | 'row';
|
|
6868
6884
|
renderPopup?: (props: TrainingBlockButtonPopupProps) => ReactNode;
|
|
6869
6885
|
completedIds?: Set<string>;
|
|
6870
6886
|
/** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지이고, `hideCheckBadge` 면 숨는다. */
|
|
@@ -24,5 +24,5 @@ export default function Alert({ tone = 'info', title, message, children, dismiss
|
|
|
24
24
|
const cls = cx('alert', `alert--${tone}`, className);
|
|
25
25
|
const hasTitle = Boolean(title);
|
|
26
26
|
const hasMessage = Boolean(message || children);
|
|
27
|
-
return (_jsxs("div", { className: cls, role: "alert", children: [_jsxs("div", { className: "alert__body", children: [_jsxs("div", { className: "alert__header", children: [_jsx(Icon, { icon: TONE_ICON[tone], size: 20, className: "alert__icon" }), hasTitle && _jsx("span", { className: "alert__title body-2", children: title }), !hasTitle && hasMessage && _jsx("div", { className: "alert__message body-3", children: message
|
|
27
|
+
return (_jsxs("div", { className: cls, role: "alert", children: [_jsxs("div", { className: "alert__body", children: [_jsxs("div", { className: "alert__header", children: [_jsx(Icon, { icon: TONE_ICON[tone], size: 20, className: "alert__icon" }), hasTitle && _jsx("span", { className: "alert__title body-2", children: title }), !hasTitle && hasMessage && _jsx("div", { className: "alert__message body-3", children: message || children })] }), hasTitle && hasMessage && (_jsx("div", { className: "alert__message alert__message--indented body-3", children: message || children }))] }), dismissible && (_jsx("button", { type: "button", className: "alert__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }))] }));
|
|
28
28
|
}
|
|
@@ -40,7 +40,9 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
|
|
|
40
40
|
}, [padded]);
|
|
41
41
|
const [tooltip, setTooltip] = useState(null);
|
|
42
42
|
const handleBarLeave = useCallback(() => setTooltip(null), []);
|
|
43
|
-
return (_jsxs("div", { className: "manager-bar-chart-wrap", children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (
|
|
43
|
+
return (_jsxs("div", { className: "manager-bar-chart-wrap", children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (
|
|
44
|
+
/* 눈금선은 **적힌 값의 높이**에 둔다 — 가운데 값을 반올림(최대 3 → '2')하면서 자리는 절반(1.5)에 두어 선과 숫자가 어긋났다 */
|
|
45
|
+
_jsx("div", { className: "manager-bar-chart-gridline", style: { top: (1 - t / padded) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
|
|
44
46
|
const isFuture = d.a === -1;
|
|
45
47
|
/* 판 높이를 넘지 않는다 — maxOverride(진행률 100)보다 큰 값이 와도 막대가 눈금 위로 삐져나가지 않게 */
|
|
46
48
|
const 높이 = (v) => Math.min(h, Math.max(0, (수(v) / padded) * h));
|
|
@@ -8,7 +8,7 @@ import InfoTooltip from "./Tooltip/InfoTooltip";
|
|
|
8
8
|
import DescriptionTooltip from "./Tooltip/DescriptionTooltip";
|
|
9
9
|
/** @public */
|
|
10
10
|
export default function Button(props) {
|
|
11
|
-
const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', selected, toggleable = false, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
11
|
+
const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', selected, toggleable = false, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
12
12
|
const sizeKey = typeof size === 'string' && isNaN(Number(size))
|
|
13
13
|
? size.toLowerCase()
|
|
14
14
|
: String(Number(size) || 48);
|
|
@@ -85,7 +85,7 @@ export default function Button(props) {
|
|
|
85
85
|
const ariaPressed = (toggleable || typeof selected === 'boolean')
|
|
86
86
|
? (isSelected ? 'true' : 'false')
|
|
87
87
|
: undefined;
|
|
88
|
-
const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, className);
|
|
88
|
+
const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, tooltipOnly ? 'button--tooltip-only' : null, className);
|
|
89
89
|
const iconClassName = `button__icon button__icon--${iconSize}`;
|
|
90
90
|
const subLabels = Array.isArray(subLabel) ? subLabel : (subLabel !== undefined ? [subLabel] : []);
|
|
91
91
|
const renderLabelContent = () => {
|
|
@@ -180,7 +180,7 @@ export default function Button(props) {
|
|
|
180
180
|
const Root = (isLink ? 'a' : 'button');
|
|
181
181
|
return (_jsxs(_Fragment, { children: [_jsx(Root, { ...restProps, ref: btnRef, ...(isLink
|
|
182
182
|
? { href, target, rel: rel ?? (target === '_blank' ? 'noreferrer' : undefined) }
|
|
183
|
-
: { type: htmlType, disabled: disabledProp && !onDisabledClick }), className: classes, "aria-pressed": ariaPressed, "aria-label": ariaLabelString, "aria-disabled":
|
|
183
|
+
: { type: htmlType, disabled: disabledProp && !onDisabledClick }), className: classes, "aria-pressed": ariaPressed, "aria-label": ariaLabelString, "aria-disabled": disabledProp && (isLink || !!onDisabledClick) ? true : undefined, style: style, onClick: handleClickInternal, onMouseLeave: handleMouseLeave, onPointerEnter: (e) => {
|
|
184
184
|
showTooltipFn();
|
|
185
185
|
if (!disabledProp && typeof rest.onPointerEnter === 'function')
|
|
186
186
|
rest.onPointerEnter(e);
|
|
@@ -7,9 +7,17 @@ import { useEffect, useId, useRef } from 'react';
|
|
|
7
7
|
export default function Checkbox({ checked = false, onChange, disabled, indeterminate, label, size = 'medium', className, title, ariaLabel, }) {
|
|
8
8
|
const ref = useRef(null);
|
|
9
9
|
const id = useId();
|
|
10
|
+
/* 중간 상태는 DOM 속성이라 누르는 순간 브라우저가 false 로 바꾼다. React 는 checked 만 되돌리므로 prop 이 그대로
|
|
11
|
+
true 여도(비활성 행이 있어 전체 선택이 안 되는 등) 빈 칸으로 남았다 — 누른 뒤와 다시 그릴 때마다 다시 넣는다. */
|
|
12
|
+
const indeterminateRef = useRef(!!indeterminate);
|
|
13
|
+
indeterminateRef.current = !!indeterminate;
|
|
10
14
|
useEffect(() => {
|
|
11
15
|
if (ref.current)
|
|
12
16
|
ref.current.indeterminate = !!indeterminate;
|
|
13
|
-
}
|
|
14
|
-
return (_jsxs("label", { className: cx('checkbox', size === 'small' && 'checkbox--small', size === 'large' && 'checkbox--large', disabled && 'checkbox--disabled', className), htmlFor: id, title: title, children: [_jsx("input", { ref: ref, id: id, type: "checkbox", className: "checkbox__input", checked: checked, onChange: e =>
|
|
17
|
+
});
|
|
18
|
+
return (_jsxs("label", { className: cx('checkbox', size === 'small' && 'checkbox--small', size === 'large' && 'checkbox--large', disabled && 'checkbox--disabled', className), htmlFor: id, title: title, children: [_jsx("input", { ref: ref, id: id, type: "checkbox", className: "checkbox__input", checked: checked, onChange: e => {
|
|
19
|
+
onChange?.(e.target.checked);
|
|
20
|
+
if (ref.current)
|
|
21
|
+
ref.current.indeterminate = indeterminateRef.current;
|
|
22
|
+
}, disabled: disabled, "aria-label": !label && ariaLabel ? ariaLabel : undefined }), _jsx("span", { className: "checkbox__box" }), label && _jsx("span", { className: "checkbox__label body-3", children: label })] }));
|
|
15
23
|
}
|
|
@@ -32,7 +32,8 @@ const KEYWORDS = /^(?:const|let|var|function|return|if|else|for|while|switch|cas
|
|
|
32
32
|
function tokenize(code, lang) {
|
|
33
33
|
let s = code;
|
|
34
34
|
const tokens = [];
|
|
35
|
-
|
|
35
|
+
// 이름이 **정확히** 셸일 때만 — 부분 일치라 csharp 도 셸로 읽혀 `//` 주석은 놓치고 `#region` 이 주석이 됐다
|
|
36
|
+
const isBash = /^(?:sh|bash|zsh|shell|console)$/.test(lang.toLowerCase());
|
|
36
37
|
const push = (type, content) => tokens.push({ type, content });
|
|
37
38
|
while (s.length > 0) {
|
|
38
39
|
const ws = s.match(/^\s+/);
|
|
@@ -176,6 +176,8 @@ onComplete, trigger = 0, }) {
|
|
|
176
176
|
if (now < p.startTime) {
|
|
177
177
|
// waiting to start: keep hidden
|
|
178
178
|
node.style.display = "none";
|
|
179
|
+
// 출발 전 조각도 살아 있다 — 안 세면 첫 틀이 모든 지연보다 이를 때 곧바로 끝나 한 조각도 안 났다
|
|
180
|
+
alive = true;
|
|
179
181
|
continue;
|
|
180
182
|
}
|
|
181
183
|
// initialize origin positioning once when particle starts
|
|
@@ -16,33 +16,36 @@ function IconGridMenuButton(props) {
|
|
|
16
16
|
/** @public */
|
|
17
17
|
export default function IconGridMenu(props) {
|
|
18
18
|
const { categories = [], selected, onSelect, size = 'medium' } = props || {};
|
|
19
|
-
/* `null` 은 "고르지 않음" 이라는 제어 값이다 — 첫 카테고리로 갈음하지
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
19
|
+
/* 제어 모드면 **prop 을 그대로** 그린다. `null` 은 "고르지 않음" 이라는 제어 값이다 — 첫 카테고리로 갈음하지 않는다.
|
|
20
|
+
전에는 누르는 순간 안쪽 상태를 바꿔, 부모가 값을 안 바꾸면(같은 selected 를 다시 넘기면) 화면만 다른 단추로 남았다. */
|
|
21
|
+
const isControlled = selected !== undefined;
|
|
22
|
+
const [innerKey, setInnerKey] = useState(() => (isControlled ? null : ((categories && categories[0]?.key) ?? null)));
|
|
23
|
+
const selectedKey = isControlled ? selected : innerKey;
|
|
24
|
+
/* 비제어 모드의 첫 선택 — 목록이 **처음 생겼을 때** 한 번 고르고 부모에 알린다. 마운트 때만 보면 카테고리가 늦게
|
|
25
|
+
오는 경우(`[]` 뒤 목록) 아무것도 안 골라졌고, StrictMode 의 두 번 실행에서는 알림이 두 번 갔다.
|
|
26
|
+
제어 모드에서는 부모가 이미 값을 가지므로 에코하지 않는다(부모 상태를 덮어쓸 수 있다). */
|
|
27
|
+
const onSelectRef = useRef(onSelect);
|
|
28
|
+
useEffect(() => { onSelectRef.current = onSelect; });
|
|
29
|
+
const firstPickDoneRef = useRef(false);
|
|
30
|
+
const firstKey = (categories && categories[0]?.key) ?? null;
|
|
23
31
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
24
32
|
useEffect(() => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
if (isControlled || firstPickDoneRef.current || !firstKey)
|
|
34
|
+
return;
|
|
35
|
+
firstPickDoneRef.current = true;
|
|
36
|
+
const key = innerKey ?? firstKey;
|
|
37
|
+
if (innerKey == null)
|
|
38
|
+
setInnerKey(key);
|
|
39
|
+
onSelectRef.current?.(key);
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
}, [isControlled, firstKey]);
|
|
31
42
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
32
|
-
const initialOnSelectRef = useRef(onSelect);
|
|
33
|
-
const initialKeyRef = useRef(selectedKey);
|
|
34
|
-
const isControlledRef = useRef(selected !== undefined);
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
// 비제어(uncontrolled) 모드에서만 초기 선택을 부모에 알림.
|
|
37
|
-
// 제어 모드(selected 전달)에서는 부모가 이미 값을 가지므로 에코하면 의도치 않게 부모 상태를 덮어쓸 수 있음.
|
|
38
|
-
if (!isControlledRef.current && initialKeyRef.current && typeof initialOnSelectRef.current === 'function') {
|
|
39
|
-
initialOnSelectRef.current(initialKeyRef.current);
|
|
40
|
-
}
|
|
41
|
-
}, []);
|
|
42
43
|
const handleClick = useCallback((key) => {
|
|
43
44
|
const next = selectedKey === key ? null : key;
|
|
44
|
-
|
|
45
|
+
firstPickDoneRef.current = true;
|
|
46
|
+
if (!isControlled)
|
|
47
|
+
setInnerKey(next);
|
|
45
48
|
onSelect?.(next);
|
|
46
|
-
}, [selectedKey, onSelect]);
|
|
49
|
+
}, [selectedKey, isControlled, onSelect]);
|
|
47
50
|
return (_jsx("div", { className: `icon-grid-menu icon-grid-menu--${size}`, children: (categories || []).map((t) => (_jsx(IconGridMenuButton, { category: t, isSelected: selectedKey === t.key, onClick: () => handleClick(t.key) }, t.key))) }));
|
|
48
51
|
}
|
|
@@ -18,6 +18,32 @@ export default function ConfirmPopup(props) {
|
|
|
18
18
|
// 흐름(결제/환불)에서 더블클릭 시 RPC가 2회 발화될 수 있어, 여기서 in-flight를 직접 막는다.
|
|
19
19
|
const busyRef = useRef(false);
|
|
20
20
|
const [busy, setBusy] = useState(false);
|
|
21
|
+
/* 확인·추가 행동을 **한 번만** 돌린다. 전에는 잠금을 onConfirm 이 끝나자마자 finally 에서 풀어, 닫힘 애니메이션(260ms)
|
|
22
|
+
동안 한 번 더 누르면 onConfirm 이 또 불렸다(말귀 단어표 삭제·이용권 삭제가 두 번 나가 성공 뒤 실패 토스트).
|
|
23
|
+
닫기로 끝나면 잠금을 풀지 않는다 — 창이 사라지는 중이다. 닫지 않고 끝나면(closeOnConfirm=false·실패) 풀고, 처리 중에
|
|
24
|
+
단추가 꺼지며 body 로 떨어진 포커스를 누른 단추로 되돌린다. */
|
|
25
|
+
const 한번만 = async (일) => {
|
|
26
|
+
if (busyRef.current)
|
|
27
|
+
return;
|
|
28
|
+
busyRef.current = true;
|
|
29
|
+
const 누른곳 = typeof document !== 'undefined' ? document.activeElement : null;
|
|
30
|
+
setBusy(true);
|
|
31
|
+
let 닫는중 = false;
|
|
32
|
+
try {
|
|
33
|
+
닫는중 = await 일();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
if (!닫는중) {
|
|
37
|
+
busyRef.current = false;
|
|
38
|
+
setBusy(false);
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
const 지금 = document.activeElement;
|
|
41
|
+
if ((!지금 || 지금 === document.body) && 누른곳?.isConnected)
|
|
42
|
+
누른곳.focus();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
21
47
|
return (_jsxs(Popup, { title: title, onClose: onClose,
|
|
22
48
|
// onConfirm(서버 요청 등)이 진행 중일 때 X/ESC로 닫으면, 팝업은 즉시 사라지는데
|
|
23
49
|
// 이미 시작된 요청은 계속 진행돼 완료 후 부모 state/토스트가 뒤늦게 갱신된다 —
|
|
@@ -31,19 +57,13 @@ export default function ConfirmPopup(props) {
|
|
|
31
57
|
// 없는 일(삭제·연결 해제)이라, 두 번 눌려 두 번 나가면 첫 번째만 뜻이 있고
|
|
32
58
|
// 두 번째는 엉뚱한 실패로 돌아온다.
|
|
33
59
|
onClick: async ({ closeWithAnimation }) => {
|
|
34
|
-
|
|
35
|
-
return;
|
|
36
|
-
busyRef.current = true;
|
|
37
|
-
setBusy(true);
|
|
38
|
-
try {
|
|
60
|
+
await 한번만(async () => {
|
|
39
61
|
await extraAction.onClick(closeWithAnimation);
|
|
40
|
-
|
|
62
|
+
const 닫음 = extraAction.closeOnClick ?? true;
|
|
63
|
+
if (닫음)
|
|
41
64
|
closeWithAnimation();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
busyRef.current = false;
|
|
45
|
-
setBusy(false);
|
|
46
|
-
}
|
|
65
|
+
return 닫음;
|
|
66
|
+
});
|
|
47
67
|
},
|
|
48
68
|
}] : []),
|
|
49
69
|
...(hideCancel ? [] : [{
|
|
@@ -57,19 +77,12 @@ export default function ConfirmPopup(props) {
|
|
|
57
77
|
variant: confirmVariant,
|
|
58
78
|
disabled: confirmDisabled || busy,
|
|
59
79
|
onClick: async ({ closeWithAnimation }) => {
|
|
60
|
-
|
|
61
|
-
return;
|
|
62
|
-
busyRef.current = true;
|
|
63
|
-
setBusy(true);
|
|
64
|
-
try {
|
|
80
|
+
await 한번만(async () => {
|
|
65
81
|
await onConfirm(closeWithAnimation);
|
|
66
82
|
if (closeOnConfirm)
|
|
67
83
|
closeWithAnimation();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
busyRef.current = false;
|
|
71
|
-
setBusy(false);
|
|
72
|
-
}
|
|
84
|
+
return closeOnConfirm;
|
|
85
|
+
});
|
|
73
86
|
},
|
|
74
87
|
},
|
|
75
88
|
], children: [topContent != null && _jsx("div", { className: "confirm-popup__top", children: topContent }), details && details.length > 0 && (_jsx(Panel, { children: details.map((d, i) => (_jsx(PanelRow, { label: d.label, children: _jsx("span", { className: "panel-row__text body-2", children: d.value }) }, i))) })), _jsx("p", { className: "body-2 confirm-popup__message", children: message }), children] }));
|
|
@@ -28,10 +28,13 @@ export default function CouponInputPopup({ onApply, onCancel }) {
|
|
|
28
28
|
catch (e) {
|
|
29
29
|
setError(getErrorMessage(e));
|
|
30
30
|
setLoading(false);
|
|
31
|
+
/* 처리 중 입력칸이 꺼지며 포커스가 body 로 떨어졌다 — 오류를 본 사람이 곧바로 고쳐 칠 수 있게 칸으로 되돌린다 */
|
|
32
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
31
33
|
}
|
|
32
34
|
}, [code, onApply]);
|
|
33
35
|
const handleKey = useCallback((e, closeWithAnimation) => {
|
|
34
|
-
|
|
36
|
+
// 한글 조합을 끝내는 Enter 는 적용이 아니다(TextField 와 같다)
|
|
37
|
+
if (e.key === 'Enter' && !e.nativeEvent.isComposing)
|
|
35
38
|
void handleApply(closeWithAnimation);
|
|
36
39
|
}, [handleApply]);
|
|
37
40
|
const handleChange = (e) => {
|
|
@@ -7,6 +7,7 @@ import Table from '../Table';
|
|
|
7
7
|
import Toolbar from '../Toolbar';
|
|
8
8
|
import './ListPopup.css';
|
|
9
9
|
import { 칸순 } from '../../utils/textSort';
|
|
10
|
+
import { cx } from '../../utils/cx';
|
|
10
11
|
/** @public */
|
|
11
12
|
export default function ListPopup(props) {
|
|
12
13
|
const { title, items, columns, gridTemplateColumns, searchPlaceholder, searchFn, defaultSortKey, defaultSortAsc = true, selectedId, selectedIds, onSelect, onClose, pageSizeOptions = [10, 25, 50, 100], initialPageSize, emptyText, countLabel, topContent, className = '', } = props;
|
|
@@ -52,7 +53,12 @@ export default function ListPopup(props) {
|
|
|
52
53
|
});
|
|
53
54
|
}, [items, search, sortKey, sortAsc, searchEnabled, effectiveSearchFn]);
|
|
54
55
|
const totalPages = Math.max(1, Math.ceil(filtered.length / Number(pageSize)));
|
|
55
|
-
|
|
56
|
+
/* 목록이 줄어 보던 쪽이 범위를 넘으면 끝 쪽으로 — 전에는 3쪽을 보다 항목이 5개로 줄면 '항목이 없습니다' 와 '총 5개' 가 함께
|
|
57
|
+
떴다. 렌더 중에 상태도 맞춘다. */
|
|
58
|
+
if (page > totalPages)
|
|
59
|
+
setPage(totalPages);
|
|
60
|
+
const 이쪽 = Math.min(page, totalPages);
|
|
61
|
+
const paged = filtered.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize));
|
|
56
62
|
const handleSort = useCallback((key) => {
|
|
57
63
|
if (sortKey === key)
|
|
58
64
|
setSortAsc(a => !a);
|
|
@@ -91,7 +97,7 @@ export default function ListPopup(props) {
|
|
|
91
97
|
sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
|
|
92
98
|
})), [columns, sortKey, sortAsc, handleSort]);
|
|
93
99
|
const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
|
|
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
|
|
100
|
+
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) => {
|
|
95
101
|
const isSelected = selectedIds != null
|
|
96
102
|
? (it.id != null && selectedIds.has(it.id))
|
|
97
103
|
: (selectedId != null && it.id === selectedId);
|
|
@@ -99,7 +105,8 @@ export default function ListPopup(props) {
|
|
|
99
105
|
content: (col.render
|
|
100
106
|
? col.render(it)
|
|
101
107
|
: (it[col.key] ?? '')),
|
|
102
|
-
className
|
|
108
|
+
// 고른 행에서도 열 className(정렬·폭 등)을 지킨다 — 전에는 고른 표시가 그 자리를 덮어 고른 행만 칸 모양이 달라졌다
|
|
109
|
+
className: cx(col.className, isSelected && 'popup-list__row--selected'),
|
|
103
110
|
}));
|
|
104
111
|
}, empty: resolvedEmptyText })] }));
|
|
105
112
|
}
|
|
@@ -31,7 +31,7 @@ function TableThumbnail({ table }) {
|
|
|
31
31
|
const item = table.items[i];
|
|
32
32
|
if (!item)
|
|
33
33
|
return _jsx("div", { className: "load-table-popup__thumb-cell load-table-popup__thumb-cell--empty" }, i);
|
|
34
|
-
return (_jsx("div", { className: "load-table-popup__thumb-cell", children: item.words.length >= 2 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[0].label }), _jsx("span", { className: "load-table-popup__thumb-divider" }), _jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[1].label })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[0]?.label ?? item.id }), item.words[0]?.subLabel && (_jsx("span", { className: "load-table-popup__thumb-sub", children: item.words[0].subLabel }))] })) }, i));
|
|
34
|
+
return (_jsx("div", { className: "load-table-popup__thumb-cell", children: (item.words ?? []).length >= 2 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[0].label }), _jsx("span", { className: "load-table-popup__thumb-divider" }), _jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words[1].label })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "load-table-popup__thumb-word body-3", children: item.words?.[0]?.label ?? item.id }), item.words?.[0]?.subLabel && (_jsx("span", { className: "load-table-popup__thumb-sub", children: item.words[0].subLabel }))] })) }, i));
|
|
35
35
|
}) }));
|
|
36
36
|
}
|
|
37
37
|
const DeleteButton = ({ onClick, ariaLabel }) => {
|
|
@@ -51,10 +51,11 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
51
51
|
const [viewMode, setViewMode] = useState('grid');
|
|
52
52
|
const sorted = useMemo(() => {
|
|
53
53
|
const copy = [...tables];
|
|
54
|
+
// 저장소에서 온 표는 어긋나 있을 수 있다(savedAt 없음) — 한 표 때문에 팝업 전체가 렌더 중에 터지던 것
|
|
54
55
|
if (sortKey === 'newest')
|
|
55
|
-
return copy.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
|
56
|
+
return copy.sort((a, b) => String(b.savedAt ?? '').localeCompare(String(a.savedAt ?? '')));
|
|
56
57
|
if (sortKey === 'oldest')
|
|
57
|
-
return copy.sort((a, b) => a.savedAt.localeCompare(b.savedAt));
|
|
58
|
+
return copy.sort((a, b) => String(a.savedAt ?? '').localeCompare(String(b.savedAt ?? '')));
|
|
58
59
|
if (sortKey === 'name')
|
|
59
60
|
return copy.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
|
60
61
|
if (sortKey === 'count')
|
|
@@ -81,7 +82,7 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
81
82
|
animateThen(() => onLoad(table));
|
|
82
83
|
}, [tables, selected, onLoad]);
|
|
83
84
|
const headerActions = useMemo(() => tables.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Dropdown, { options: VIEW_OPTIONS, value: viewMode, onChange: handleSetViewMode }), _jsx(Dropdown, { options: SORT_OPTIONS, value: sortKey, onChange: handleSetSortKey })] })) : undefined, [tables.length, viewMode, sortKey, handleSetViewMode, handleSetSortKey, VIEW_OPTIONS, SORT_OPTIONS]);
|
|
84
|
-
return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", table.items.length,
|
|
85
|
+
return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' })] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: (item.words ?? []).length >= 2
|
|
85
86
|
? `${item.words[0].label} · ${item.words[1].label}`
|
|
86
|
-
: item.words[0]?.label ?? item.id }, i))) })] }),
|
|
87
|
+
: item.words?.[0]?.label ?? item.id }, i))) })] }), _jsx("span", { className: "load-table-popup__row-count caption-1", children: t('wordTable.loadTable.itemCount', { count: table.items.length, defaultValue: '{{count}}개' }) }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
|
|
87
88
|
}
|
|
@@ -34,6 +34,15 @@
|
|
|
34
34
|
.popup-panel--small { max-width: 480px; }
|
|
35
35
|
.popup-panel--medium { max-width: 680px; }
|
|
36
36
|
.popup-panel--large { max-width: 1080px; }
|
|
37
|
+
.popup-panel--xlarge { width: calc(100% - 80px); max-width: 1320px; }
|
|
38
|
+
/* fit — 내용 폭. 최소·최대 모두 화면 폭 - 40px 을 넘지 않는다(좁은 화면에서 480px 로 잘리지 않게) */
|
|
39
|
+
.popup-panel--fit {
|
|
40
|
+
width: fit-content;
|
|
41
|
+
min-width: min(480px, calc(100% - 40px));
|
|
42
|
+
max-width: min(1140px, calc(100% - 40px));
|
|
43
|
+
}
|
|
44
|
+
/* 층 — 훈련 페이지 팝업 바로 아래(그 창이 이 창을 덮는다). inline 은 제 자리 규칙을 따른다 */
|
|
45
|
+
.popup.popup--below-page-popup:not(.popup--inline) { z-index: calc(var(--z-page-popup) - 1); }
|
|
37
46
|
.popup-close-float {
|
|
38
47
|
position: absolute;
|
|
39
48
|
top: 8px;
|
|
@@ -70,9 +70,17 @@ export interface PopupProps {
|
|
|
70
70
|
* - `small` 480px (확인/알림류)
|
|
71
71
|
* - `medium` 680px (입력 폼)
|
|
72
72
|
* - `large` 1080px (리스트/다중 콘텐츠)
|
|
73
|
+
* - `xlarge` 1320px, 폭은 화면 - 80px(좌우 40px) — 훈련 카드 만들기처럼 화면을 넓게 쓰는 창
|
|
74
|
+
* - `fit` 내용 폭에 맞춘다 — 최소 480px·최대 1140px(둘 다 화면 폭 - 40px 을 넘지 않는다).
|
|
75
|
+
* 한 줄에 카드가 몇 장 들어가느냐로 폭이 정해지는 목록 창에 쓴다.
|
|
73
76
|
* 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
|
|
74
77
|
*/
|
|
75
|
-
size?: 'small' | 'medium' | 'large';
|
|
78
|
+
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fit';
|
|
79
|
+
/**
|
|
80
|
+
* 쌓이는 층. 기본 `modal`(`--z-modal`) 은 훈련 페이지 팝업(`--z-page-popup`) 위에 뜬다.
|
|
81
|
+
* `belowPagePopup` 은 그 **바로 아래** 에 깐다 — 이 창에서 연 훈련 팝업이 이 창을 덮어야 할 때(실행 창 등).
|
|
82
|
+
*/
|
|
83
|
+
layer?: 'modal' | 'belowPagePopup';
|
|
76
84
|
/**
|
|
77
85
|
* 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
|
|
78
86
|
*
|
|
@@ -13,7 +13,7 @@ const ANIM_MS = 260;
|
|
|
13
13
|
/** @public */
|
|
14
14
|
export default function Popup(props) {
|
|
15
15
|
const { t } = useTranslation();
|
|
16
|
-
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, height, resetScrollKey } = props || {};
|
|
16
|
+
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, resetScrollKey } = props || {};
|
|
17
17
|
const [closing, setClosing] = useState(false);
|
|
18
18
|
// 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
|
|
19
19
|
// 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
|
|
@@ -154,7 +154,7 @@ export default function Popup(props) {
|
|
|
154
154
|
if (pressed && e.target === e.currentTarget)
|
|
155
155
|
closeWithAnimation();
|
|
156
156
|
};
|
|
157
|
-
const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
|
|
157
|
+
const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${layer === 'belowPagePopup' ? 'popup--below-page-popup' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
|
|
158
158
|
/* 바탕을 눌러도 포커스는 판 안에 둔다 — 닫히지 않는 바탕(closeOnBackdrop={false}·closeDisabled·beforeClose 가
|
|
159
159
|
거절)을 누르면 포커스가 body 로 떨어져, 이어서 누른 Tab 이 뒤 화면으로 나갔다(가둠은 판 안의 키만 듣는다).
|
|
160
160
|
mousedown 의 기본 동작(포커스 옮김)만 막고 click 은 그대로 온다. */
|
|
@@ -41,10 +41,20 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
|
|
|
41
41
|
}, [previewW, previewH, preview]);
|
|
42
42
|
const isDuplicateName = !!existingNames &&
|
|
43
43
|
existingNames.some(n => n.trim().toLowerCase() === name.trim().toLowerCase());
|
|
44
|
-
|
|
44
|
+
/* 저장은 **한 번만** — 저장 단추의 닫힘 애니메이션(260ms) 동안 이름칸에서 Enter 를 치면 onSave 가 또 불렸다(Safari 는 단추를
|
|
45
|
+
눌러도 포커스가 칸에 남아 쉽게 난다). */
|
|
46
|
+
const 저장함 = useRef(false);
|
|
47
|
+
const handleSave = useCallback((animateThen) => {
|
|
48
|
+
if (저장함.current)
|
|
49
|
+
return;
|
|
50
|
+
저장함.current = true;
|
|
51
|
+
animateThen(() => onSave(name.trim(), description.trim() || undefined));
|
|
52
|
+
}, [name, description, onSave]);
|
|
45
53
|
const handleEnter = useCallback(() => {
|
|
46
|
-
if (name.trim()
|
|
47
|
-
|
|
54
|
+
if (저장함.current || !name.trim() || isDuplicateName)
|
|
55
|
+
return;
|
|
56
|
+
저장함.current = true;
|
|
57
|
+
onSave(name.trim(), description.trim() || undefined);
|
|
48
58
|
}, [name, description, isDuplicateName, onSave]);
|
|
49
59
|
return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
|
|
50
60
|
width: previewW, height: previewH,
|
|
@@ -95,7 +95,11 @@ export default function TrainingResultPopup(props) {
|
|
|
95
95
|
// 래퍼(.trp-result__scroll)를 하나 더 두어 이중 스크롤이 됐고, 안쪽이 overscroll:contain으로
|
|
96
96
|
// 휠을 삼키면서 "커서가 바깥 스크롤바 위에 있을 때만 스크롤되는" 문제가 있었다.
|
|
97
97
|
// 하단 버튼은 Popup의 footer 슬롯으로 넘겨 스크롤과 무관하게 고정된다.
|
|
98
|
-
return (_jsx(Popup, { onClose: onClose,
|
|
98
|
+
return (_jsx(Popup, { onClose: onClose,
|
|
99
|
+
/* 바탕 누르기로는 닫지 않는다 — 이 창의 닫기(onClose)는 '완료'(기록 확정 + 훈련 닫기)라, 결과를 보다 어두운 바탕을
|
|
100
|
+
잘못 누르면 기록이 확정되고 '다시하기' 를 쓸 수 없었다(0.128.0 에서 Popup 바탕 닫기가 기본이 되며 생김).
|
|
101
|
+
완료·다시하기 단추(와 Esc)로만 닫는다. */
|
|
102
|
+
closeOnBackdrop: false, inline: inline,
|
|
99
103
|
/* 이 팝업은 Popup 의 title 슬롯을 안 쓰고 본문 안에 제 제목(h1)을 그린다 —
|
|
100
104
|
그래서 다이얼로그 자체에 접근명이 없었다(운영 경고 9건, 08-17 ~ 08-30,
|
|
101
105
|
/today · /training · /my-word-training · /my-homework). 화면에 보이는 그
|
|
@@ -77,10 +77,12 @@ export function visitItemKey(v) {
|
|
|
77
77
|
* 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
|
|
78
78
|
*/
|
|
79
79
|
export function formatDuration(sec, durationStr) {
|
|
80
|
-
|
|
80
|
+
// 초 단위로 반올림한 뒤 나눈다 — 90.5 가 "1분 30.5초", 59.999 가 "59.999초" 로 찍혔다
|
|
81
|
+
const 초 = Number.isFinite(sec) ? Math.round(sec) : 0;
|
|
82
|
+
if (!초 || 초 <= 0)
|
|
81
83
|
return '-';
|
|
82
|
-
const m = Math.floor(
|
|
83
|
-
const s =
|
|
84
|
+
const m = Math.floor(초 / 60);
|
|
85
|
+
const s = 초 % 60;
|
|
84
86
|
if (durationStr)
|
|
85
87
|
return durationStr(m, s);
|
|
86
88
|
// 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
|
|
@@ -24,6 +24,9 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
|
|
|
24
24
|
const safeMax = Number.isFinite(max) && max > 0 ? max : 1;
|
|
25
25
|
const clamped = Math.max(0, Math.min(Number.isFinite(value) ? value : 0, safeMax));
|
|
26
26
|
const pct = (clamped / safeMax) * 100;
|
|
27
|
+
/* 글자·aria 의 최댓값은 **받은 값** 이다(0 이하·못 읽는 값은 0) — safeMax 는 나눗셈을 지키려는 것뿐인데 라벨에도 써서
|
|
28
|
+
항목이 아직 없을 때(max=0) "0 / 1" 로 읽혔다 */
|
|
29
|
+
const shownMax = Number.isFinite(max) && max > 0 ? max : 0;
|
|
27
30
|
// 자리를 지정하지 않으면 굵기가 정한다 — 얇은 막대 안에는 글자가 들어가지 않는다.
|
|
28
31
|
// center 는 inside 의 다른 이름이라 여기서 하나로 모은다(클래스가 갈리지 않게).
|
|
29
32
|
const rawPos = labelPos ?? (size >= INSIDE_LABEL_MIN_SIZE ? 'inside' : 'bottom');
|
|
@@ -43,9 +46,9 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
|
|
|
43
46
|
rootStyle['--progress-bar-fill'] = color;
|
|
44
47
|
if (trackColor)
|
|
45
48
|
rootStyle['--progress-bar-track'] = trackColor;
|
|
46
|
-
return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax":
|
|
49
|
+
return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": shownMax, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
|
|
47
50
|
? formatValue(clamped)
|
|
48
51
|
: label === 'percent' ? `${Math.round(pct)}%`
|
|
49
52
|
: label === 'raw' ? String(clamped)
|
|
50
|
-
: `${clamped} / ${
|
|
53
|
+
: `${clamped} / ${shownMax}` }))] }));
|
|
51
54
|
}
|
|
@@ -2,6 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import useMediaQuery from '../hooks/useMediaQuery';
|
|
4
4
|
import './ProgressIndicator.css';
|
|
5
|
+
/** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
|
|
6
|
+
const 보이지않게 = {
|
|
7
|
+
position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
|
|
8
|
+
clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
|
|
9
|
+
};
|
|
5
10
|
/**
|
|
6
11
|
* transparent 일 때 남은 단계의 배경.
|
|
7
12
|
*
|
|
@@ -42,7 +47,7 @@ export default function ProgressIndicator({ max, index, inactiveBg, inactiveColo
|
|
|
42
47
|
묶고 이름표에 자리를 적는다. 화면이 스스로 넘어가는 자리에서는
|
|
43
48
|
announce 로 role="status" 를 켠다. */
|
|
44
49
|
if (variant === 'dot') {
|
|
45
|
-
return (
|
|
50
|
+
return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: 보이지않게, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
|
|
46
51
|
}
|
|
47
52
|
// 모바일: 현재 단계만 표시 (n / max 형태)
|
|
48
53
|
if (isMobile) {
|
|
@@ -79,7 +79,9 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
|
|
|
79
79
|
const star = i + 1;
|
|
80
80
|
const fillRatio = Math.max(0, Math.min(1, displayValue - i));
|
|
81
81
|
const isHalf = fillRatio > 0 && fillRatio < 1;
|
|
82
|
-
const starNode = (_jsxs("span", { className: "rating__star-wrap", children: [_jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--empty" }), fillRatio > 0 && (_jsx("span", { className: `rating__star-fill${isHalf ? ' rating__star-fill--half' : ''}`,
|
|
82
|
+
const starNode = (_jsxs("span", { className: "rating__star-wrap", children: [_jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--empty" }), fillRatio > 0 && (_jsx("span", { className: `rating__star-fill${isHalf ? ' rating__star-fill--half' : ''}`,
|
|
83
|
+
// 조각 별은 **값만큼** 채운다 — 반 별 클래스(50%)만 두어 4.1 과 4.9 가 같은 반 별로 그려졌다(이름은 "4.1")
|
|
84
|
+
style: isHalf ? { width: `${fillRatio * 100}%` } : undefined, children: _jsx(Icon, { icon: "star", size: iconSize, className: "rating__star rating__star--filled" }) }))] }));
|
|
83
85
|
if (isReadOnly) {
|
|
84
86
|
return (_jsx("span", { className: "rating__item", "aria-hidden": "true", children: starNode }, star));
|
|
85
87
|
}
|