@ihabdevteam/core 0.128.0 → 0.131.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 +100 -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-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 +282 -80
- 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/BottomNav.css +15 -0
- package/dist/packages/core/src/components/BottomNav.d.ts +6 -0
- package/dist/packages/core/src/components/BottomNav.js +12 -1
- 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.d.ts +6 -0
- package/dist/packages/core/src/components/Header.js +27 -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/PassCard/PassCardBg.css +33 -0
- 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 +1 -1
- 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/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 +34 -6
- package/dist/packages/core/src/i18n/locales/ko.json +34 -6
- package/dist/packages/core/src/tokens.css +2 -1
- package/dist/packages/core/src/utils/CategoryData.d.ts +12 -40
- package/dist/packages/core/src/utils/CategoryData.js +15 -10
- 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
|
@@ -13,8 +13,10 @@ const VALUE_UPDATE_MS = 250;
|
|
|
13
13
|
// 실제 dB SPL(물리 음압)은 기기·볼륨 캘리브레이션 없이는 알 수 없어, 대신 임의 기준점
|
|
14
14
|
// 대비 상대값임을 라벨(dBFS가 아닌 dB)과 부호로 드러낸다.
|
|
15
15
|
// 부호는 항상 표시(우측 정렬 + 고정폭 박스라 자릿수가 바뀌어도 숫자 끝 위치는 고정된다).
|
|
16
|
+
// 기준점 대비 상대 dB 를 표시 범위로 조이고 반올림한 값 — 글자와 aria-valuenow 가 같은 수를 쓴다.
|
|
17
|
+
const relDbValue = (dbfs, refDb, displayMin, displayMax) => Math.round(Math.min(displayMax, Math.max(displayMin, dbfs - refDb)));
|
|
16
18
|
const formatRelDb = (dbfs, refDb, displayMin, displayMax) => {
|
|
17
|
-
const rel =
|
|
19
|
+
const rel = relDbValue(dbfs, refDb, displayMin, displayMax);
|
|
18
20
|
const sign = rel < 0 ? '-' : '+';
|
|
19
21
|
return `${sign}${Math.abs(rel)} dB`;
|
|
20
22
|
};
|
|
@@ -24,6 +26,7 @@ const formatRelDb = (dbfs, refDb, displayMin, displayMax) => {
|
|
|
24
26
|
* @public
|
|
25
27
|
*/
|
|
26
28
|
export default function AudioMeter({ analyser, audioElement, orientation = 'horizontal', length, label, labelPos, labelAlign = 'right', floorDb = -60, refDb = -35, displayMin = -5, displayMax = 25, className, ariaLabel, }) {
|
|
29
|
+
const rootRef = useRef(null);
|
|
27
30
|
const fillRef = useRef(null);
|
|
28
31
|
const valueRef = useRef(null);
|
|
29
32
|
const rafRef = useRef(null);
|
|
@@ -31,12 +34,24 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
|
|
|
31
34
|
audioMeter 머리말). resume 뒤 태워지면 그 노드로 다시 돈다. */
|
|
32
35
|
const [routed, setRouted] = useState(null);
|
|
33
36
|
useEffect(() => {
|
|
37
|
+
/* role="meter" 는 값이 있어야 미터다 — 예전엔 aria-valuenow·min·max 가 없어 낭독기가
|
|
38
|
+
"미터" 라고만 읽었다. 값은 **글자와 같은 주기(VALUE_UPDATE_MS)** 로만 바꾼다: 매 프레임
|
|
39
|
+
속성을 바꾸면 낭독기가 쉴 새 없이 값 변화를 잡는다. 리렌더 없이 속성만 고친다(막대와 같은 방식). */
|
|
40
|
+
const writeValue = (dbfs) => {
|
|
41
|
+
const text = formatRelDb(dbfs, refDb, displayMin, displayMax);
|
|
42
|
+
if (valueRef.current)
|
|
43
|
+
valueRef.current.textContent = text;
|
|
44
|
+
const root = rootRef.current;
|
|
45
|
+
if (root) {
|
|
46
|
+
root.setAttribute('aria-valuenow', String(relDbValue(dbfs, refDb, displayMin, displayMax)));
|
|
47
|
+
root.setAttribute('aria-valuetext', text);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
34
50
|
const node = analyser ?? getElementAnalyser(audioElement) ?? (routed && routed.el === audioElement ? routed.node : null);
|
|
35
51
|
const fill = fillRef.current;
|
|
36
52
|
if (!node || !fill) {
|
|
37
53
|
fill?.style.setProperty('--meter-level', '0');
|
|
38
|
-
|
|
39
|
-
valueRef.current.textContent = formatRelDb(floorDb, refDb, displayMin, displayMax);
|
|
54
|
+
writeValue(floorDb);
|
|
40
55
|
let cancelled = false;
|
|
41
56
|
if (!analyser && audioElement) {
|
|
42
57
|
void routeElementAudio(audioElement).then((n) => { if (!cancelled && n)
|
|
@@ -66,17 +81,16 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
|
|
|
66
81
|
const relDb = dbClamped - refDb;
|
|
67
82
|
const fillRatio = Math.min(1, Math.max(0, (relDb - displayMin) / (displayMax - displayMin)));
|
|
68
83
|
fill.style.setProperty('--meter-level', fillRatio.toFixed(3));
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
84
|
+
// 글자가 없어도(label 생략) aria-valuenow 는 갱신해야 하므로 평균은 늘 낸다
|
|
85
|
+
dbSum += dbClamped;
|
|
86
|
+
dbCount += 1;
|
|
87
|
+
if (now - lastUpdate >= VALUE_UPDATE_MS) {
|
|
88
|
+
const windowAvg = dbSum / dbCount;
|
|
89
|
+
displayedDb = displayedDb == null ? windowAvg : displayedDb * 0.6 + windowAvg * 0.4;
|
|
90
|
+
writeValue(displayedDb);
|
|
91
|
+
dbSum = 0;
|
|
92
|
+
dbCount = 0;
|
|
93
|
+
lastUpdate = now;
|
|
80
94
|
}
|
|
81
95
|
rafRef.current = requestAnimationFrame(tick);
|
|
82
96
|
};
|
|
@@ -95,5 +109,5 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
|
|
|
95
109
|
const track = (_jsx("div", { className: "audio-meter__track", style: trackStyle, children: _jsx("div", { ref: fillRef, className: "audio-meter__fill" }) }));
|
|
96
110
|
// 자리를 지정하지 않으면 방향이 정한다 — 가로 막대 옆(오른쪽), 세로 막대 아래.
|
|
97
111
|
const pos = labelPos ?? (orientation === 'vertical' ? 'bottom' : 'right');
|
|
98
|
-
return (_jsxs("div", { className: cx('audio-meter', `audio-meter--${orientation}`, label && `audio-meter--label-${pos}`, label && `audio-meter--align-${labelAlign}`, className), role: "meter", "aria-label": ariaLabel, children: [track, label && (_jsx("span", { ref: valueRef, className: "audio-meter__value caption-2", children: formatRelDb(floorDb, refDb, displayMin, displayMax) }))] }));
|
|
112
|
+
return (_jsxs("div", { ref: rootRef, className: cx('audio-meter', `audio-meter--${orientation}`, label && `audio-meter--label-${pos}`, label && `audio-meter--align-${labelAlign}`, className), role: "meter", "aria-label": ariaLabel, "aria-valuemin": displayMin, "aria-valuemax": displayMax, "aria-valuenow": relDbValue(floorDb, refDb, displayMin, displayMax), "aria-valuetext": formatRelDb(floorDb, refDb, displayMin, displayMax), children: [track, label && (_jsx("span", { ref: valueRef, className: "audio-meter__value caption-2", children: formatRelDb(floorDb, refDb, displayMin, displayMax) }))] }));
|
|
99
113
|
}
|
|
@@ -22,10 +22,14 @@ function getInitials(name) {
|
|
|
22
22
|
*/
|
|
23
23
|
export default function Avatar({ src, name, size = 'md', shape = 'circle', className = '' }) {
|
|
24
24
|
const { t } = useTranslation();
|
|
25
|
-
|
|
25
|
+
// 실패는 **그 주소에 대해서만** 기억한다. 불리언 하나로 두면 한 번 깨진 뒤 새 src 가 와도
|
|
26
|
+
// 계속 이니셜로 남았다(목록 줄을 재사용하거나 사진을 바꿔 올린 경우). 렌더 중에 비교하므로
|
|
27
|
+
// 이펙트로 되돌리는 한 틀 깜빡임도 없다.
|
|
28
|
+
const [failedSrc, setFailedSrc] = useState(null);
|
|
29
|
+
const imgFailed = !!src && failedSrc === src;
|
|
26
30
|
const initials = name ? getInitials(name) : '';
|
|
27
31
|
const showImage = !!src && !imgFailed;
|
|
28
32
|
const showInitials = !showImage && !!initials;
|
|
29
33
|
const label = name || t('avatar.ariaLabel', '사용자 아바타');
|
|
30
|
-
return (_jsxs("span", { className: `avatar avatar--${size} avatar--${shape} ${className}`.trim(), role: "img", "aria-label": label, children: [showImage && (_jsx("img", { className: "avatar__image", src: src, alt: label, onError: () =>
|
|
34
|
+
return (_jsxs("span", { className: `avatar avatar--${size} avatar--${shape} ${className}`.trim(), role: "img", "aria-label": label, children: [showImage && (_jsx("img", { className: "avatar__image", src: src, alt: label, onError: () => setFailedSrc(src ?? null) })), !showImage && showInitials && (_jsx("span", { className: "avatar__initials", "aria-hidden": "true", children: initials })), !showImage && !showInitials && (_jsx(Icon, { icon: "user-round", size: ICON_SIZE[size], className: "avatar__icon" }))] }));
|
|
31
35
|
}
|
|
@@ -27,6 +27,10 @@ export interface BadgeProps {
|
|
|
27
27
|
*/
|
|
28
28
|
size?: 'small' | 'medium' | 'large' | BadgeDotSize;
|
|
29
29
|
className?: string;
|
|
30
|
+
/**
|
|
31
|
+
* 접근성 이름. dot 은 주지 않으면 모양에 따라 '선택됨'·'제거'·'제외'·'N번째 선택' 으로 짓고,
|
|
32
|
+
* bar 는 주면 그 이름으로 읽힌다(안 주면 글자 그대로 읽힌다).
|
|
33
|
+
*/
|
|
30
34
|
'aria-label'?: string;
|
|
31
35
|
label?: string;
|
|
32
36
|
children?: ReactNode;
|
|
@@ -54,7 +58,7 @@ export interface BadgeProps {
|
|
|
54
58
|
/** 우측 아이콘 (bar 전용). Lucide 아이콘 이름 또는 ReactNode */
|
|
55
59
|
iconRight?: ReactNode | string;
|
|
56
60
|
/**
|
|
57
|
-
* 아이콘 종류 (dot 전용)
|
|
61
|
+
* 아이콘 종류 (dot 전용). `number` 가 없고 이것도 없으면 `check` 로 그린다.
|
|
58
62
|
* - `check` — 선택/완료 (파란 체크)
|
|
59
63
|
* - `remove` — 제거 (빨간 ×)
|
|
60
64
|
* - `minus` — 비활성/제외 (회색 —)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
3
|
import { cx } from '../utils/cx';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
3
5
|
import Icon from './Icon';
|
|
4
6
|
import './Badge.css';
|
|
5
7
|
// ── 내부 아이콘 ────────────────────────────────────────────────────────────
|
|
@@ -15,17 +17,26 @@ function MinusIcon() {
|
|
|
15
17
|
// ── 컴포넌트 ───────────────────────────────────────────────────────────────
|
|
16
18
|
/** @public */
|
|
17
19
|
export default function Badge(props) {
|
|
20
|
+
const { t } = useTranslation();
|
|
18
21
|
const { type = 'bar', size, className = '', 'aria-label': ariaLabel, } = props;
|
|
19
22
|
// ── dot (원형) ──────────────────────────────────────────────────────────
|
|
20
23
|
if (type === 'dot') {
|
|
21
24
|
const { icon, number, position = 'top-right', onClick, animate = false } = props;
|
|
22
|
-
|
|
25
|
+
// icon 을 안 주면 check 다 — 클래스(파란 바탕)·기본 이름('선택됨')이 이미 check 로 가는데
|
|
26
|
+
// 그리는 쪽만 `icon === 'check'` 을 봐서 **체크 없는 파란 원**이 나왔다. 셋이 같은 값을 보게 한다.
|
|
27
|
+
const dotIcon = icon ?? 'check';
|
|
28
|
+
const dotVariant = number !== undefined ? 'number' : dotIcon;
|
|
23
29
|
// dot 크기는 Button과 같은 숫자 스케일 — 문자열이 오면 기본값(32)으로 떨어뜨린다.
|
|
24
30
|
const dotSize = typeof size === 'number' ? size : 32;
|
|
25
31
|
const cls = cx('circle-badge', `circle-badge--${dotVariant}`, `circle-badge--${position}`, `circle-badge--${dotSize}`, animate ? 'circle-badge--animate' : '', onClick ? 'circle-badge--clickable' : '', className);
|
|
32
|
+
// 기본 이름을 한국어로 박아 두면 영문 화면에서도 낭독기만 한국어로 읽었다 — 사전을 거친다.
|
|
26
33
|
const defaultLabel = number !== undefined
|
|
27
|
-
?
|
|
28
|
-
: {
|
|
34
|
+
? t('badge.nthSelected', { n: number, defaultValue: '{{n}}번째 선택' })
|
|
35
|
+
: {
|
|
36
|
+
check: t('badge.selected', '선택됨'),
|
|
37
|
+
remove: t('badge.remove', '제거'),
|
|
38
|
+
minus: t('badge.exclude', '제외'),
|
|
39
|
+
}[dotIcon];
|
|
29
40
|
return (_jsxs("span", { className: cls, onClick: onClick,
|
|
30
41
|
/* `<span role="button">` 은 브라우저가 Enter·Space 를 눌림으로 바꿔 주지 않는다.
|
|
31
42
|
tabIndex 만 있으면 키보드 사용자는 **그 자리에 멈추고도 아무것도 못 한다** —
|
|
@@ -38,7 +49,7 @@ export default function Badge(props) {
|
|
|
38
49
|
내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
|
|
39
50
|
e.preventDefault();
|
|
40
51
|
e.currentTarget.click();
|
|
41
|
-
} : undefined, "aria-label": ariaLabel ?? defaultLabel, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined &&
|
|
52
|
+
} : undefined, "aria-label": ariaLabel ?? defaultLabel, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined && dotIcon === 'check' && _jsx(CheckIcon, {}), number === undefined && dotIcon === 'remove' && _jsx(RemoveIcon, {}), number === undefined && dotIcon === 'minus' && _jsx(MinusIcon, {})] }));
|
|
42
53
|
}
|
|
43
54
|
// ── bar (텍스트) ────────────────────────────────────────────────────────
|
|
44
55
|
const { label, children, variant = 'default', fill = 'filled', minWidth, iconLeft, iconRight, color } = props;
|
|
@@ -58,5 +69,8 @@ export default function Badge(props) {
|
|
|
58
69
|
...(resolvedColor ? { '--badge-color': resolvedColor } : {}),
|
|
59
70
|
}
|
|
60
71
|
: undefined;
|
|
61
|
-
return (
|
|
72
|
+
return (
|
|
73
|
+
// aria-label 을 받아 놓고 bar 에서는 버렸다 — dot 과 같이 건넨다. 이름만 두면 역할 없는
|
|
74
|
+
// span 이라 낭독기가 무시하므로 이름이 있을 때만 role="img" 를 붙인다(안 주면 예전 그대로).
|
|
75
|
+
_jsxs("span", { className: cls, style: style, "aria-label": ariaLabel, role: ariaLabel ? 'img' : undefined, children: [iconLeft && _jsx(Icon, { icon: iconLeft, size: iconSize, className: "badge__icon" }), label ?? children, iconRight && _jsx(Icon, { icon: iconRight, size: iconSize, className: "badge__icon" })] }));
|
|
62
76
|
}
|
|
@@ -38,6 +38,21 @@
|
|
|
38
38
|
overflow: hidden;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/* 단추 한 칸 — 단추와 같은 52px 이라 넘침 계산이 그대로다. 배지(.circle-badge--top-right)의 기준.
|
|
42
|
+
배지는 Badge 기본 코너(-6px)가 아니라 칸 안쪽 모서리(0)에 둔다 — .bottom-nav__items 가
|
|
43
|
+
overflow: hidden 이라 바깥으로 나간 6px 가 잘린다(넘침 계산을 위해 그 hidden 은 둬야 한다). */
|
|
44
|
+
.bottom-nav__slot {
|
|
45
|
+
position: relative;
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-shrink: 0;
|
|
48
|
+
width: 52px;
|
|
49
|
+
height: 52px;
|
|
50
|
+
}
|
|
51
|
+
.bottom-nav__slot .circle-badge--top-right {
|
|
52
|
+
top: 0;
|
|
53
|
+
right: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
.bottom-nav__right {
|
|
42
57
|
display: flex;
|
|
43
58
|
align-items: center;
|
|
@@ -6,6 +6,12 @@ export type NavItem = {
|
|
|
6
6
|
label?: string;
|
|
7
7
|
icon?: string;
|
|
8
8
|
state?: string;
|
|
9
|
+
/**
|
|
10
|
+
* 단추 우상단 원형 숫자 배지(SideNav 항목 배지와 같은 모양). 0/undefined 면 숨기고, 99 에서 자른다.
|
|
11
|
+
* 배지는 낭독기에서 숨기고 개수는 단추 이름에 붙인다. 넘쳐 "…" 메뉴로 가면 메뉴 항목의 배지가 되고,
|
|
12
|
+
* "…" 단추에는 넘친 항목 개수의 합이 선다.
|
|
13
|
+
*/
|
|
14
|
+
badgeCount?: number;
|
|
9
15
|
};
|
|
10
16
|
/**
|
|
11
17
|
* 항상 "더보기(…)" 메뉴에만 노출되는 유틸 항목(알림·설정·계정 등).
|
|
@@ -4,6 +4,7 @@ import { useCallback, useId, useMemo, useState, useRef, useEffect } from 'react'
|
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import './BottomNav.css';
|
|
6
6
|
import Button from './Button';
|
|
7
|
+
import Badge from './Badge';
|
|
7
8
|
import MenuTooltip from './Tooltip/MenuTooltip';
|
|
8
9
|
// .tooltip-content(메뉴 표면)를 직접 쓰므로 정의 파일을 명시적으로 가져온다.
|
|
9
10
|
// Tooltip.tsx만 이 CSS를 import하는데 BottomNav는 그 컴포넌트를 거치지 않아,
|
|
@@ -13,6 +14,8 @@ import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
|
|
|
13
14
|
import { useTranslation } from 'react-i18next';
|
|
14
15
|
const BTN_SIZE = 52;
|
|
15
16
|
const BTN_GAP = 10;
|
|
17
|
+
/** 배지 숫자 — SideNav·MenuTooltip 과 같이 99 에서 자른다. 0 이하·없음은 0. */
|
|
18
|
+
const badgeOf = (n) => (n && n > 0 ? n : 0);
|
|
16
19
|
/** @public */
|
|
17
20
|
export default function BottomNav({ items = [], activeKey, onChange, appMode, onAppModeChange, moreItems = [], }) {
|
|
18
21
|
const { t } = useTranslation();
|
|
@@ -59,6 +62,8 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
59
62
|
onScroll: 'ignore',
|
|
60
63
|
onResize: 'ignore',
|
|
61
64
|
});
|
|
65
|
+
/* 단추 이름에 개수를 붙인다 — 배지는 aria-hidden 이라 개수는 이름으로만 전해진다. */
|
|
66
|
+
const nameWithCount = useCallback((label, count) => count > 0 ? t('nav.badgeLabel', { label, count, defaultValue: '{{label}}, {{count}}개' }) : label, [t]);
|
|
62
67
|
const handleModeChange = useCallback(() => onAppModeChange?.(nextMode), [onAppModeChange, nextMode]);
|
|
63
68
|
// 넘친 nav 항목 또는 유틸 항목이 현재 활성 상태면 … 버튼에 그 아이콘/선택표시를 반영
|
|
64
69
|
const activeMore = useMemo(() => hiddenItems.find(it => it.key === activeKey) ?? moreItems.find(it => it.selected || (it.key && it.key === activeKey)), [hiddenItems, activeKey, moreItems]);
|
|
@@ -68,6 +73,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
68
73
|
...hiddenItems.map(it => ({
|
|
69
74
|
label: it.label,
|
|
70
75
|
icon: it.icon,
|
|
76
|
+
badgeCount: it.badgeCount,
|
|
71
77
|
selected: it.key === activeKey,
|
|
72
78
|
closeOnSelect: true,
|
|
73
79
|
onClick: () => {
|
|
@@ -89,5 +95,10 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
89
95
|
})),
|
|
90
96
|
]
|
|
91
97
|
}], [hiddenItems, moreItems, activeKey, onChange, handleCloseMore]);
|
|
92
|
-
|
|
98
|
+
// "…" 단추 배지 — 메뉴 안에서만 보이는 개수(넘친 항목 + 유틸)를 닫힌 채로도 알린다.
|
|
99
|
+
const moreBadge = [...hiddenItems, ...moreItems].reduce((sum, it) => sum + badgeOf(it.badgeCount), 0);
|
|
100
|
+
return (_jsxs("nav", { className: "bottom-nav", "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", ref: itemsRef, children: visibleItems.map((it) => {
|
|
101
|
+
const count = badgeOf(it.badgeCount);
|
|
102
|
+
return (_jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key));
|
|
103
|
+
}) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: nameWithCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
|
|
93
104
|
}
|
|
@@ -11,6 +11,7 @@ export interface BulkBarProps {
|
|
|
11
11
|
count: number;
|
|
12
12
|
actions: BulkAction[];
|
|
13
13
|
onClear: () => void;
|
|
14
|
+
/** 선택 해제 단추 글자. 기본은 사전의 `bulkBar.clearSelection`('선택 해제') */
|
|
14
15
|
clearLabel?: string;
|
|
15
16
|
/** count > 0 일 때 자동으로 visible */
|
|
16
17
|
visible?: boolean;
|
|
@@ -19,4 +20,4 @@ export interface BulkBarProps {
|
|
|
19
20
|
/** 제공하면 액션 버튼 왼쪽에 "count / max" 뱃지를 표시 */
|
|
20
21
|
max?: number;
|
|
21
22
|
}
|
|
22
|
-
export default function BulkBar({ count, actions, onClear, clearLabel, visible, content, max, }: BulkBarProps): import("react").JSX.Element;
|
|
23
|
+
export default function BulkBar({ count, actions, onClear, clearLabel: clearLabelProp, visible, content, max, }: BulkBarProps): import("react").JSX.Element;
|
|
@@ -5,9 +5,11 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import './BulkBar.css';
|
|
6
6
|
import Button from './Button';
|
|
7
7
|
import Badge from './Badge';
|
|
8
|
-
export default function BulkBar({ count, actions, onClear, clearLabel
|
|
8
|
+
export default function BulkBar({ count, actions, onClear, clearLabel: clearLabelProp, visible, content, max, }) {
|
|
9
9
|
const { t } = useTranslation();
|
|
10
10
|
const show = visible !== undefined ? visible : count > 0;
|
|
11
|
+
/* 기본 문구도 사전에서 — 예전에는 '선택 해제' 가 박혀 영어 화면에서도 한국어였다 */
|
|
12
|
+
const clearLabel = clearLabelProp ?? t('bulkBar.clearSelection', '선택 해제');
|
|
11
13
|
/* 내용이 한 줄이면 액션을 가로로, 두 줄 이상이면 세로로 둔다.
|
|
12
14
|
줄바꿈 여부를 높이 문턱값으로 재면 칩 크기나 글꼴이 바뀔 때마다 틀리므로,
|
|
13
15
|
자식들이 실제로 같은 줄에 있는지(offsetTop)를 본다. */
|
|
@@ -36,5 +38,10 @@ export default function BulkBar({ count, actions, onClear, clearLabel = '선택
|
|
|
36
38
|
obs.observe(el);
|
|
37
39
|
return () => obs.disconnect();
|
|
38
40
|
}, [content, count]);
|
|
39
|
-
return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
|
|
41
|
+
return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
|
|
42
|
+
/* 숨은 막대는 CSS 로 투명(opacity 0)할 뿐 DOM 에 그대로 있다 — 예전에는 그 안의
|
|
43
|
+
단추가 Tab 순서와 낭독기에 남아, 보이지 않는 '선택 해제'·'삭제' 에 포커스가 갔다.
|
|
44
|
+
inert 로 포커스·낭독기에서 함께 뺀다(보이는 모양은 CSS 그대로). aria-hidden 은
|
|
45
|
+
inert 를 모르는 오래된 브라우저를 위한 덧댐이다. */
|
|
46
|
+
inert: !show || undefined, "aria-hidden": show ? undefined : true, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}` })), _jsxs("div", { className: `bulk-bar__actions${stacked ? ' bulk-bar__actions--stacked' : ''}`, children: [_jsx(Button, { size: 40, label: clearLabel, onClick: onClear }), actions.map((a, i) => (_jsx(Button, { size: 40, variant: a.variant ?? 'default', icon: a.icon, label: a.label, onClick: a.onClick }, i)))] })] }));
|
|
40
47
|
}
|
|
@@ -52,7 +52,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
52
52
|
setMonth(m => m + 1); }, [month]);
|
|
53
53
|
const fmtDate = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
54
54
|
const isToday = (d) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === d;
|
|
55
|
-
return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
|
|
55
|
+
return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { type: "button", className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { type: "button", className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
|
|
56
56
|
if (d === null)
|
|
57
57
|
return _jsx("div", { className: "calendar-cell empty" }, `empty-${i}`);
|
|
58
58
|
const day = d;
|
|
@@ -12,6 +12,8 @@ export interface ColorPickerProps {
|
|
|
12
12
|
/** 스와치 크기 — Button과 동일한 체계 (32 / 40 / 48), radius도 Button rounded와 동일하게 맞춤 */
|
|
13
13
|
size?: ColorPickerSize;
|
|
14
14
|
className?: string;
|
|
15
|
+
/** 묶음의 이름(예: '글자 색') — 낭독기가 무엇의 색을 고르는지 읽는다 */
|
|
16
|
+
ariaLabel?: string;
|
|
15
17
|
}
|
|
16
18
|
/** @public */
|
|
17
|
-
export default function ColorPicker({ value, onChange, palette, size, className, }: ColorPickerProps): import("react").JSX.Element;
|
|
19
|
+
export default function ColorPicker({ value, onChange, palette, size, className, ariaLabel, }: ColorPickerProps): import("react").JSX.Element;
|
|
@@ -14,9 +14,11 @@ const DEFAULT_PALETTE = [
|
|
|
14
14
|
'#bfc7d1', // disabled
|
|
15
15
|
];
|
|
16
16
|
/** @public */
|
|
17
|
-
export default function ColorPicker({ value, onChange, palette = DEFAULT_PALETTE, size = 40, className = '', }) {
|
|
17
|
+
export default function ColorPicker({ value, onChange, palette = DEFAULT_PALETTE, size = 40, className = '', ariaLabel, }) {
|
|
18
18
|
const { t } = useTranslation();
|
|
19
|
-
return (
|
|
19
|
+
return (
|
|
20
|
+
/* role="group" 은 이름이 없으면 낭독기가 묶음을 알리지 않는다 — 이름을 받는다 */
|
|
21
|
+
_jsx("div", { className: `color-picker ${className}`.trim(), role: "group", "aria-label": ariaLabel, children: palette.map(hex => {
|
|
20
22
|
const isSelected = value?.toLowerCase() === hex.toLowerCase();
|
|
21
23
|
return (_jsx("button", { type: "button", className: `color-picker__swatch color-picker__swatch--${size}`, style: { backgroundColor: hex }, onClick: () => onChange(hex), "aria-pressed": isSelected, "aria-label": isSelected ? `${hex} ${t('colorPicker.selected', '선택됨')}` : hex, children: isSelected && (_jsx(Icon, { icon: "check", size: CHECK_ICON_SIZE, className: "color-picker__check" })) }, hex));
|
|
22
24
|
}) }));
|
|
@@ -7,7 +7,8 @@ export interface DatePickerProps {
|
|
|
7
7
|
min?: string;
|
|
8
8
|
max?: string;
|
|
9
9
|
disabled?: boolean;
|
|
10
|
+
/** 값이 없을 때 보이는 문구. 기본은 사전의 `datePicker.chooseDate`('날짜 선택') */
|
|
10
11
|
placeholder?: string;
|
|
11
12
|
className?: string;
|
|
12
13
|
}
|
|
13
|
-
export default function DatePicker({ value, onChange, size, min, max, disabled, placeholder, className, }: DatePickerProps): import("react").JSX.Element;
|
|
14
|
+
export default function DatePicker({ value, onChange, size, min, max, disabled, placeholder: placeholderProp, className, }: DatePickerProps): import("react").JSX.Element;
|
|
@@ -28,7 +28,9 @@ function formatDisplay(iso) {
|
|
|
28
28
|
const [y, m, d] = iso.split('-');
|
|
29
29
|
return `${y}. ${m}. ${d}.`;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
/* 연월 이름은 `연월이름` 으로 받는다 — 예전에는 `${y}년 ${m + 1}월` 로 박혀 있어 영어 화면에서도
|
|
32
|
+
한국어 연월이 떴다. 부르는 쪽이 사전(`calendar.yearMonth`, Calendar 와 같은 열쇠)을 넘긴다. */
|
|
33
|
+
function buildYMOptions(viewY, 연월이름, min, max) {
|
|
32
34
|
const startY = min ? +min.split('-')[0] : viewY - 10;
|
|
33
35
|
const endY = max ? +max.split('-')[0] : viewY + 10;
|
|
34
36
|
const opts = [];
|
|
@@ -39,13 +41,16 @@ function buildYMOptions(viewY, min, max) {
|
|
|
39
41
|
continue;
|
|
40
42
|
if (max && ym > max.slice(0, 7))
|
|
41
43
|
continue;
|
|
42
|
-
opts.push({ value: ym, label:
|
|
44
|
+
opts.push({ value: ym, label: 연월이름(y, m + 1) });
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
return opts;
|
|
46
48
|
}
|
|
47
|
-
export default function DatePicker({ value, onChange, size = 48, min, max, disabled, placeholder
|
|
49
|
+
export default function DatePicker({ value, onChange, size = 48, min, max, disabled, placeholder: placeholderProp, className, }) {
|
|
48
50
|
const { t } = useTranslation();
|
|
51
|
+
/* 기본 안내 문구도 사전에서 — 예전에는 '날짜 선택' 이 박혀 있어 영어 화면에서도 한국어였다.
|
|
52
|
+
달력 판의 이름(`datePicker.chooseDate`)과 같은 말이라 같은 열쇠를 쓴다. */
|
|
53
|
+
const placeholder = placeholderProp ?? t('datePicker.chooseDate', '날짜 선택');
|
|
49
54
|
/* 요일은 **사전에서** 가져온다. 예전에는 이 파일에 `['일','월',…]` 로 박혀 있어
|
|
50
55
|
영어 화면의 날짜 고르개만 한국어 요일이 떴다 — 같은 저장소의 `Calendar` 는
|
|
51
56
|
진작 `calendar.dow` 를 쓰고 있었고, 사전에는 영어(`Sun`·`Mon`…)도 들어 있었다. */
|
|
@@ -200,7 +205,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
|
|
|
200
205
|
for (let d = 1; d <= daysInMonth(viewY, viewM); d++)
|
|
201
206
|
cells.push(d);
|
|
202
207
|
const iconSize = size === 64 ? 20 : size === 40 ? 16 : 18;
|
|
203
|
-
const ymOptions = buildYMOptions(viewY, min, max);
|
|
208
|
+
const ymOptions = buildYMOptions(viewY, (year, month) => t('calendar.yearMonth', { year, month, defaultValue: '{{year}}년 {{month}}월' }), min, max);
|
|
204
209
|
const ymValue = `${viewY}-${String(viewM + 1).padStart(2, '0')}`;
|
|
205
210
|
const dropdown = open ? createPortal(_jsxs("div", { ref: menuRef, className: "date-picker__dropdown", style: dropdownStyle, role: "dialog", "aria-label": t('datePicker.chooseDate', '날짜 선택'), tabIndex: -1, children: [_jsxs("div", { className: "date-picker__nav", children: [_jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: prevMonth, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 2 }) }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYearMonth', '연월 선택'), options: ymOptions, value: ymValue, onChange: (v) => {
|
|
206
211
|
const [y, m] = v.split('-');
|
|
@@ -2,10 +2,19 @@ import "./EffectPartyPopper.css";
|
|
|
2
2
|
/** @public */
|
|
3
3
|
export default function EffectPartyPopper({ active, count, duration, strength, // multiplier for speed/spread
|
|
4
4
|
onComplete, trigger, }: {
|
|
5
|
+
/** true 인 동안 그린다. false 면 아무것도 안 그린다. */
|
|
5
6
|
active?: boolean;
|
|
7
|
+
/** 조각 수. 좌우로 반씩 나눈다. 기본 40. */
|
|
6
8
|
count?: number;
|
|
9
|
+
/**
|
|
10
|
+
* 조각 수명의 기준(ms). 값에 비례해 모든 조각이 더 오래 또는 더 짧게 날다 사라진다 — 기본 2000 에서
|
|
11
|
+
* 조각 하나는 약 1.5~5.5초 난다(조각마다 다르고, strength 가 크면 조금 길다).
|
|
12
|
+
*/
|
|
7
13
|
duration?: number;
|
|
14
|
+
/** 속도·퍼짐 배율. 기본 1. */
|
|
8
15
|
strength?: number;
|
|
16
|
+
/** 모든 조각이 사라지면 부른다(움직임 최소화 설정이면 바로 부른다). */
|
|
9
17
|
onComplete?: () => void;
|
|
18
|
+
/** 값이 바뀌면 다시 터진다. */
|
|
10
19
|
trigger?: number;
|
|
11
20
|
}): import("react").JSX.Element | null;
|
|
@@ -5,6 +5,27 @@ import { createPortal } from "react-dom";
|
|
|
5
5
|
import "./EffectPartyPopper.css";
|
|
6
6
|
// use only the app's two CSS variables for colors
|
|
7
7
|
const COLORS = ["var(--color-main)", "var(--color-sub)"];
|
|
8
|
+
// `duration` 의 기준값. 조각 수명은 이 값일 때의 모양(대략 1.2~4초)을 그대로 두고 duration 에
|
|
9
|
+
// 비례해 늘거나 준다 — 기본값을 준 화면은 예전과 똑같이 터진다.
|
|
10
|
+
const BASE_DURATION = 2000;
|
|
11
|
+
/**
|
|
12
|
+
* CSS 변수 색(`var(--color-main)`)을 **그 조각 원소 자신** 기준으로 푼다.
|
|
13
|
+
*
|
|
14
|
+
* 예전에는 `document.documentElement` 에서 읽었다. 그런데 스코프 CSS(styles.css)는 토큰을
|
|
15
|
+
* `:root` 가 아니라 `.ihabdevteam-core-scope` 에 두므로, 스코프 안에 붙은 폭죽은 `<html>` 에서
|
|
16
|
+
* 토큰을 못 찾아 늘 폴백색으로 칠해졌다. 원소 자신의 계산된 스타일은 상속된 토큰을 본다.
|
|
17
|
+
*/
|
|
18
|
+
function resolveColor(el, color) {
|
|
19
|
+
if (!color.startsWith('var('))
|
|
20
|
+
return color;
|
|
21
|
+
try {
|
|
22
|
+
const varName = color.slice(4, -1).trim();
|
|
23
|
+
return getComputedStyle(el).getPropertyValue(varName).trim() || null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
8
29
|
/** @public */
|
|
9
30
|
export default function EffectPartyPopper({ active = false, count = 40, duration = 2000, strength = 1, // multiplier for speed/spread
|
|
10
31
|
onComplete, trigger = 0, }) {
|
|
@@ -127,8 +148,11 @@ onComplete, trigger = 0, }) {
|
|
|
127
148
|
const kY = clamp(speedY, 0.8, 2.4);
|
|
128
149
|
const kX = clamp(speedX, 0.7, 2.2);
|
|
129
150
|
// initialize particle DOM nodes (they exist but are empty) and start time per particle
|
|
151
|
+
// duration 을 받아 놓고 아무 데도 안 썼다 — 수명에 비례로 건다(기본값이면 배율 1).
|
|
152
|
+
const lifeScale = duration > 0 ? duration / BASE_DURATION : 1;
|
|
130
153
|
const particles = pieces.map((p) => ({
|
|
131
154
|
...p,
|
|
155
|
+
life: p.life * lifeScale,
|
|
132
156
|
vx: p.vx * kX,
|
|
133
157
|
vy: p.vy * kY,
|
|
134
158
|
startTime: start + p.delay,
|
|
@@ -164,19 +188,9 @@ onComplete, trigger = 0, }) {
|
|
|
164
188
|
node.style.display = "block";
|
|
165
189
|
// ensure particle is above overlays
|
|
166
190
|
node.style.zIndex = '100000';
|
|
167
|
-
// if color is a CSS variable (e.g. var(--color-main)), resolve it now
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const varName = p.color.slice(4, -1).trim();
|
|
171
|
-
const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
|
172
|
-
if (resolved)
|
|
173
|
-
node.style.background = resolved;
|
|
174
|
-
else
|
|
175
|
-
node.style.background = '#FF0060';
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
catch (e) {
|
|
179
|
-
// ignore
|
|
191
|
+
// if color is a CSS variable (e.g. var(--color-main)), resolve it now — 조각 원소 기준으로
|
|
192
|
+
if (typeof p.color === 'string' && p.color.startsWith('var(')) {
|
|
193
|
+
node.style.background = resolveColor(node, p.color) ?? '#FF0060';
|
|
180
194
|
}
|
|
181
195
|
p._inited = true;
|
|
182
196
|
}
|
|
@@ -241,18 +255,9 @@ onComplete, trigger = 0, }) {
|
|
|
241
255
|
s.style.top = '0px';
|
|
242
256
|
s.style.width = `${p.size}px`;
|
|
243
257
|
s.style.height = `${Math.max(4, p.size * 0.6)}px`;
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (typeof bg === 'string' && bg.startsWith('var(')) {
|
|
248
|
-
const varName = bg.slice(4, -1).trim();
|
|
249
|
-
const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
|
250
|
-
if (resolved)
|
|
251
|
-
bg = resolved;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
catch (e) { }
|
|
255
|
-
s.style.background = bg;
|
|
258
|
+
// 아직 문서에 안 붙은 원소라 여기서는 색을 못 푼다 — var() 그대로 두고,
|
|
259
|
+
// 조각이 출발할 때(step 의 첫 틀) 붙은 원소 기준으로 푼다.
|
|
260
|
+
s.style.background = p.color;
|
|
256
261
|
s.style.borderRadius = p.borderRadius;
|
|
257
262
|
s.style.transform = 'translate3d(0,0,0)';
|
|
258
263
|
s.style.opacity = '0';
|
|
@@ -3,5 +3,7 @@ import { cx } from '../utils/cx';
|
|
|
3
3
|
import './EmptyState.css';
|
|
4
4
|
import Icon from './Icon';
|
|
5
5
|
export default function EmptyState({ icon, emoji, title, message, hint, action, compact, className }) {
|
|
6
|
-
return (_jsxs("div", { className: cx('empty-state', compact && 'empty-state--compact', className), children: [emoji && _jsx("span", { className: "empty-state__emoji", children: emoji }), icon && _jsx(Icon, { icon: icon, className: "empty-state__icon" }), title && _jsx("p", { className: "empty-state__title body-2", children: title }), message && _jsx("p", { className: "empty-state__message body-3", children: message }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), action && (
|
|
6
|
+
return (_jsxs("div", { className: cx('empty-state', compact && 'empty-state--compact', className), children: [emoji && _jsx("span", { className: "empty-state__emoji", children: emoji }), icon && _jsx(Icon, { icon: icon, className: "empty-state__icon" }), title && _jsx("p", { className: "empty-state__title body-2", children: title }), message && _jsx("p", { className: "empty-state__message body-3", children: message }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), action && (
|
|
7
|
+
// type="button" — 없으면 폼 안에서 누르는 순간 폼이 제출된다
|
|
8
|
+
_jsxs("button", { type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: action.onClick, children: [action.icon && _jsx(Icon, { icon: action.icon, className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: action.label })] }))] }));
|
|
7
9
|
}
|
|
@@ -7,5 +7,7 @@ import './ErrorState.css';
|
|
|
7
7
|
import Icon from './Icon';
|
|
8
8
|
export default function ErrorState({ message, hint, icon = 'triangle-alert', onRetry, retryLabel, className }) {
|
|
9
9
|
const { t } = useTranslation();
|
|
10
|
-
return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
|
|
10
|
+
return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
|
|
11
|
+
// type="button" — 없으면 폼 안에서 다시 시도를 누르는 순간 폼이 제출된다
|
|
12
|
+
type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: onRetry, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
|
|
11
13
|
}
|
|
@@ -11,6 +11,7 @@ import Dropdown from './Dropdown';
|
|
|
11
11
|
export { FILTER_ALL_VALUE } from '../types/filterPanel';
|
|
12
12
|
import { FILTER_ALL_VALUE } from '../types/filterPanel';
|
|
13
13
|
import FilterSectionInfo from './FilterSectionInfo';
|
|
14
|
+
import { devWarnOnce } from '../utils/devWarn';
|
|
14
15
|
/** @public */
|
|
15
16
|
export default function FilterPanel({ title, headerAction, collapsible = false, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, footer, sections, selected = {}, onSelect, align, sectionAlign, className, }) {
|
|
16
17
|
// align 우선. 없으면 구 prop(sectionAlign) 매핑, 그것도 없으면 inline 기본.
|
|
@@ -36,6 +37,12 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
|
|
|
36
37
|
// 바로 터진다 — 호출부 데이터 소스(단어 목록 필터링 등) 결함을 여기서까지 전파시키지
|
|
37
38
|
// 않도록 렌더 직전에 걸러낸다
|
|
38
39
|
const items = (section.items ?? []).filter((item) => item != null);
|
|
40
|
+
/* "전체" 보기(allOption)는 값 FILTER_ALL_VALUE('전체') 로 알린다. 보기 중에 같은 값 '전체' 가
|
|
41
|
+
있으면 그 보기를 눌러도 소비자 판정이 "전체" 로 읽고, segmented·dropdown 에서는 열쇠가 겹친다.
|
|
42
|
+
저장된 선택과의 호환 때문에 값은 못 바꾸므로 개발 중에 알린다. */
|
|
43
|
+
if (section.allOption && items.some((item) => (typeof item === 'string' ? item : item.value) === FILTER_ALL_VALUE)) {
|
|
44
|
+
devWarnOnce(`filter-panel-all-value:${section.label}`, `[FilterPanel] '${section.label}' 절에 allOption 과 값 '${FILTER_ALL_VALUE}' 인 보기가 함께 있다 — 두 선택이 구별되지 않는다. 보기 값을 바꿔라.`);
|
|
45
|
+
}
|
|
39
46
|
/* 보기 버튼 한 개. 칸으로 나뉜 절(section.groups)과 안 나뉜 절이 같은 버튼을
|
|
40
47
|
그리는데, 한때 이 25줄을 두 벌 적어 두었다. 새 속성을 하나 붙일 때 한쪽만
|
|
41
48
|
붙는 것이 자연스러운 모양이라 갈릴 수밖에 없다. */
|
|
@@ -21,8 +21,14 @@ export declare function BrandIdentity({ appMode }: {
|
|
|
21
21
|
export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange }: {
|
|
22
22
|
activeKey?: string | null;
|
|
23
23
|
onChange?: (key?: string) => void;
|
|
24
|
+
/** 지금 모드의 내비 항목. 데스크톱은 SideNav, 모바일은 BottomNav 가 이 목록을 그린다. */
|
|
24
25
|
items?: SideNavItem[];
|
|
26
|
+
/**
|
|
27
|
+
* @deprecated 읽지 않는다 — 지금 모드의 목록을 `items` 로 넘긴다.
|
|
28
|
+
* 모드 전환 때 부르는 쪽이 `items` 를 바꿔 넣으므로 헤더가 두 목록을 따로 들고 있을 까닭이 없다.
|
|
29
|
+
*/
|
|
25
30
|
malgwiItems?: SideNavItem[];
|
|
31
|
+
/** @deprecated 읽지 않는다 — 지금 모드의 목록을 `items` 로 넘긴다(`malgwiItems` 와 같다). */
|
|
26
32
|
managerItems?: SideNavItem[];
|
|
27
33
|
unreadCount?: number;
|
|
28
34
|
onDebug?: () => void;
|