@ihabdevteam/core 0.199.0 → 0.201.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +186 -186
  7. package/dist/ihabdevteam-core.d.ts +304 -18
  8. package/dist/packages/core/src/components/Header.css +143 -73
  9. package/dist/packages/core/src/components/Header.js +8 -2
  10. package/dist/packages/core/src/components/ItemActions.css +12 -0
  11. package/dist/packages/core/src/components/ItemActions.d.ts +95 -0
  12. package/dist/packages/core/src/components/ItemActions.js +82 -0
  13. package/dist/packages/core/src/components/Popup/useDiscardGuard.d.ts +48 -0
  14. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +56 -0
  15. package/dist/packages/core/src/components/SideNav.css +13 -1
  16. package/dist/packages/core/src/components.d.ts +4 -0
  17. package/dist/packages/core/src/components.js +2 -0
  18. package/dist/packages/core/src/hooks/useListControls.d.ts +66 -0
  19. package/dist/packages/core/src/hooks/useListControls.js +52 -0
  20. package/dist/packages/core/src/hooks.d.ts +1 -0
  21. package/dist/packages/core/src/hooks.js +1 -0
  22. package/dist/packages/core/src/i18n/locales/en.json +5 -0
  23. package/dist/packages/core/src/i18n/locales/ko.json +5 -0
  24. package/dist/packages/core/src/utils/chunkSkew.d.ts +27 -0
  25. package/dist/packages/core/src/utils/chunkSkew.js +44 -0
  26. package/dist/packages/core/src/utils/format.d.ts +14 -14
  27. package/dist/packages/core/src/utils/format.js +27 -1
  28. package/dist/packages/core/src/utils/sessionTab.d.ts +36 -0
  29. package/dist/packages/core/src/utils/sessionTab.js +36 -0
  30. package/dist/packages/core/src/utils.d.ts +4 -2
  31. package/dist/packages/core/src/utils.js +3 -2
  32. package/dist/packages/core/src/version.d.ts +1 -1
  33. package/dist/packages/core/src/version.js +1 -1
  34. package/dist/utils.cjs.js +2 -2
  35. package/package.json +1 -1
@@ -74,6 +74,45 @@
74
74
  background: rgba(255,255,255,0.25);
75
75
  }
76
76
 
77
+ /* **막대는 옅은 흰색이다**(사장님 지시 2026-09-27) — 얹으면 짙어진다.
78
+
79
+ 브랜드 색(`--color-main` → 얹으면 `--color-sub`)으로 한 번 가 봤다가 되돌렸다.
80
+ 이 지면이 바로 그 두 색 사이를 지나가는 그라디언트라, **막대가 어딘가에서 지면과
81
+ 같아진다** — 재 보니 main 은 레일 맨 위에서 1.06, sub 은 맨 아래에서 1.01 이었다.
82
+ 게다가 둘이 엇갈려서 위쪽에서는 얹으면 잘 보이고 아래쪽에서는 얹으면 **덜** 보였다.
83
+ 색을 고르는 것으로는 못 푸는 자리다 — 지면이 색을 갈아입으니까.
84
+
85
+ 투명도로 푼다. 흰빛을 옅게 얹으면 지면이 무슨 색이든 **그만큼 밝아져** 어디서나
86
+ 같은 만큼 떠오른다. 이 블록의 구분선(`rgba(255,255,255,0.25)`)이 쓰는 방식과 같다.
87
+
88
+ **재어 둔다 — 정하는 것은 사람이다**(RELEASE.md "색은 문을 두지 않는다").
89
+ 220×518 레일에서 가장 나쁜 자리(지면이 가장 밝은 아래쪽)의 대비다:
90
+
91
+ alpha 0.25 0.4 0.5 0.6 0.75 0.9 1.0
92
+ 대비 1.18 1.30 1.39 1.49 1.65 1.82 1.94
93
+
94
+ **흰빛으로는 3.0 에 못 간다** — 꽉 채운 흰색도 1.94 다. 아래쪽 지면(민트)이 이미 밝아서,
95
+ 거기서 3.0 을 얻으려면 막대가 **어두워져야** 한다. 그러면 위쪽(파랑)에서 또 뒤집힌다.
96
+ 지금 값(0.4 → 얹으면 0.75)은 그 안에서 고른 것이고, 브랜드 색과 달리 **어디서도 지면과
97
+ 같아지지 않고 얹으면 늘 더 보인다**(가장 나쁜 자리에서도 1.30 → 1.65). */
98
+ .header--manager .side-nav__scroll-thumb {
99
+ background: rgba(255, 255, 255, 0.4);
100
+ }
101
+ .header--manager .side-nav__scroll-thumb:hover,
102
+ .header--manager .side-nav__scroll-thumb:active {
103
+ background: rgba(255, 255, 255, 0.75);
104
+ }
105
+
106
+ /* **못 쓰는 항목은 회색이 아니라 옅은 흰색이다.** 기본 꼴의 `--color-lightGray` 는
107
+ 흰 지면에서 물러나 보이라고 고른 색인데, 이 지면에서는 푸른 회색이 그라디언트 위에
108
+ 떠서 **글자는 희고 아이콘만 탁한** 꼴이 됐다(아이콘 규칙이 0,3,0 이라 매니저 규칙
109
+ 0,2,0 을 이긴다 — 사장님 지적 2026-09-27). 물러나는 일은 줄에 이미 걸린
110
+ `opacity: 0.6` 이 한다. */
111
+ .header--manager .side-nav__item.disabled,
112
+ .header--manager .side-nav__item.disabled .side-nav__icon {
113
+ color: var(--color-deepWhite);
114
+ }
115
+
77
116
  /* 메뉴 아이템 기본 */
78
117
  .header--manager .side-nav__item {
79
118
  color: var(--color-deepWhite);
@@ -126,113 +165,139 @@
126
165
  .header__bottom { display: none; }
127
166
  }
128
167
 
129
- /* Tablet: narrow left rail 60px */
168
+ /* Tablet: 72px 접힌 레일 — 마우스를 얹거나 초점이 들어가면 220px 으로 펼쳐진다.
169
+
170
+ **펼친 꼴은 넓은 화면과 같아야 한다**(사장님 지시 2026-09-27). 그래서 이 블록은
171
+ "접힌 꼴만 덮고, 펼치면 아무것도 안 덮는다" 로 적는다 — 덮은 것을 하나하나 되돌리는
172
+ 대신 **애초에 안 덮는다.**
173
+
174
+ 예전에는 되돌리는 쪽이었다: 접힌 꼴 규칙마다 `:hover` 짝을 하나씩 두어 되돌렸다.
175
+ 그러면 **짝을 빠뜨린 것이 조용히 남는다** — 실제로 넓은 화면과 여섯 군데가 달랐다
176
+ (실측 2026-09-27): 목록 여백 11 → 8, 항목 사이 12 → 10, 모서리 10 → 12,
177
+ 이름표 `max-width` none → 200, 그리고 **하위 항목의 들여쓰기 30 → 16**. 마지막 것이
178
+ 눈에 띄는 자리다 — 되돌리는 규칙(`padding: 0 16px`)이 하위 항목 규칙(0,1,0)보다 세서
179
+ 들여쓰기가 통째로 사라졌고, 그 짝은 애초에 적힌 적이 없다.
180
+
181
+ 안 덮으면 되돌릴 것도 없다. 새 규칙이 넓은 화면에 생겨도 펼친 레일이 저절로 따라간다. */
130
182
  @media (min-width: 768px) and (max-width: 1023px) {
131
183
  .header {
132
184
  width: 72px;
185
+ transition: width 180ms ease;
186
+ overflow: visible;
187
+ }
188
+ .header:hover,
189
+ .header:focus-within {
190
+ width: 220px; /* match desktop */
191
+ }
192
+ /* JS 가 접힌 채로 못 박으면(예: 화면을 옮긴 뒤) 호버해도 안 펼쳐진다 */
193
+ .header.header--force-collapsed,
194
+ .header.header--force-collapsed:hover,
195
+ .header.header--force-collapsed:focus-within {
196
+ width: 72px;
133
197
  }
198
+
134
199
  .header__branding { height: 80px; }
135
- /* Collapsed: rounded square button (52x52) centered in 72px rail */
200
+
201
+ /* ── 두 꼴에 모두 — 움직임과 스크롤바 ─────────────────────────────────────
202
+ **접힌 레일에서는 스크롤바 자리를 비우지 않는다.** 72px 에서 양쪽 gutter(클래식
203
+ 11px × 2)를 떼면 항목이 28px 로 짓눌려 24px 아이콘이 10px 삐져나간다(실측).
204
+ 아이콘만 있는 좁은 레일에는 막대를 그릴 자리가 애초에 없다 — 막대는 숨기고
205
+ 휠·터치·키보드 스크롤은 그대로 둔다. 넓은 화면도 막대를 숨기므로(제 막대를
206
+ 따로 그린다) 이 줄은 두 꼴에 같다. */
136
207
  .header .side-nav__menu {
137
- padding: 0 var(--spacing-xs);
138
208
  transition: padding 180ms ease;
139
- /* **접힌 레일에서는 스크롤바 자리를 비우지 않는다.**
140
- 72px 에서 양쪽 gutter(클래식 11px × 2)를 떼면 항목이 28px 로 짓눌려 24px
141
- 아이콘이 10px 삐져나간다(실측). 아이콘만 있는 좁은 레일에는 스크롤바를 그릴
142
- 자리가 애초에 없다 — 막대는 숨기고 휠·터치·키보드 스크롤은 그대로 둔다.
143
- 좌우는 padding 만으로 같다(실측 8px 기준: 좌 9 / 우 9, 항목 52). */
144
209
  scrollbar-gutter: auto;
145
210
  scrollbar-width: none;
146
211
  }
147
212
  .header .side-nav__menu::-webkit-scrollbar { display: none; }
148
213
  .header .side-nav__item {
149
- /* (52px item - 24px icon) / 2 = 14px to center icon when collapsed */
150
- padding: 0 14px;
151
- gap: 0;
152
- border-radius: var(--radius-card);
153
- /* **단추를 52 로 못 박고 가운데 세운다**(사장님 지시 2026-09-25). 여백만 8px 으로 낮추면
154
- 항목이 자리를 다 먹어 54 가 되고, 위 14px 셈이 어긋나 아이콘이 1px 오른쪽으로 밀린다.
155
- 52 로 두면 그 셈이 그대로 맞아 아이콘이 14 / 14 로 선다. */
156
- width: 52px;
157
- margin: 0 auto;
158
214
  transition: padding 180ms ease, gap 180ms ease, width 180ms ease;
159
215
  }
160
-
161
- /* **발치도 가운데로.** 발치 줄은 좌우 여백이 없어(`padding: 0`) 단추 40px 이 왼쪽에 붙었고,
162
- 레일 가운데(37)보다 **5px 왼쪽**에 서서 위 아이콘들과 눈에 띄게 어긋났다(말귀 실측
163
- 2026-09-25 — 사장님이 보신 어긋남이 이것이다). 펼친 사이드바에서는 발치도 폭을 꽉 채우므로
164
- 여기서만 가운데로 세운다. */
165
- .header .side-nav__footer {
166
- align-items: center;
216
+ /* 이름표는 **접으면 자리까지 없앤다**(`max-width: 0`) — `visibility` 로만 숨기면
217
+ 그만큼 자리를 먹어 아이콘이 가운데를 벗어난다. 그 값은 아래 접힌 꼴에 있다. */
218
+ .header .side-nav__label {
219
+ display: inline-block;
220
+ overflow: hidden;
221
+ white-space: nowrap;
222
+ transition: max-width 180ms ease, opacity 180ms ease, transform 180ms ease;
167
223
  }
168
224
 
169
- .header:hover .side-nav__menu,
170
- .header:focus-within .side-nav__menu {
225
+ /* ── 접힌 꼴만 ────────────────────────────────────────────────────────────
226
+ 펼치면(`:hover`·`:focus-within`) 이 아래가 통째로 안 걸리고 넓은 화면 규칙이 그대로
227
+ 산다. 못 박아 접어 둔 레일은 호버해도 안 펼쳐지므로 늘 걸린다 — 그래서 선택자가
228
+ 둘씩이다. */
229
+ .header:not(:hover):not(:focus-within) .side-nav__menu,
230
+ .header--force-collapsed .side-nav__menu {
231
+ /* 좌우는 여백만으로 같다(실측 8px 기준: 좌 9 / 우 9, 항목 52) */
171
232
  padding: 0 var(--spacing-xs);
172
233
  }
173
234
 
174
- .header:hover .side-nav__item,
175
- .header:focus-within .side-nav__item {
176
- padding: 0 16px;
177
- gap: 10px;
178
- /* 레일이 220px 로 펼쳐지는 동안에는 **폭 고정을 푼다** — 안 풀면 항목만 52px 로 남아
179
- 이름표가 설 자리가 없다(접힌 꼴의 가운데 맞춤이 펼친 꼴까지 따라오면 안 된다). */
180
- width: 100%;
181
- margin: 0;
235
+ .header:not(:hover):not(:focus-within) .side-nav__item,
236
+ .header--force-collapsed .side-nav__item {
237
+ /* (52px 항목 - 24px 아이콘) / 2 = 14px — 접힌 꼴에서 아이콘을 가운데 세운다 */
238
+ padding: 0 14px;
239
+ gap: 0;
240
+ border-radius: var(--radius-card);
241
+ /* **단추를 52 로 못 박고 가운데 세운다**(사장님 지시 2026-09-25). 여백만 8px 으로 낮추면
242
+ 항목이 자리를 다 먹어 54 가 되고, 위 14px 셈이 어긋나 아이콘이 1px 오른쪽으로 밀린다. */
243
+ width: 52px;
244
+ margin: 0 auto;
245
+ /* 아래 배지의 기준 */
246
+ position: relative;
182
247
  }
183
248
 
184
- /* 발치도 같다 — 펼쳐지면 줄이 폭을 꽉 채운다 */
185
- .header:hover .side-nav__footer,
186
- .header:focus-within .side-nav__footer {
187
- align-items: stretch;
188
- }
249
+ /* **배지는 단추 오른쪽 위로.** 인라인 배지는 이름표 뒤에 서는데, 접히면 이름표가 폭 0 이라
250
+ 배지가 아이콘 바로 옆에 붙는다 — 단추(52)를 10px 넘어서고 통이 그 자리를 잘라, 동그라미가
251
+ 반쯤 베인 채로 남았다(실측 2026-09-27: 단추 344~396, 배지 382~406, 통 안쪽 끝 397.
252
+ 사장님이 짚으신 그림이 이것이다).
189
253
 
190
- /* Hide label completely from layout when collapsed; fade in on hover */
191
- .header .side-nav__label {
192
- display: inline-block;
193
- max-width: 0;
194
- opacity: 0;
195
- overflow: hidden;
196
- transform: translateX(-6px);
197
- transition: max-width 180ms ease, opacity 180ms ease, transform 180ms ease;
198
- white-space: nowrap;
199
- pointer-events: none;
200
- }
254
+ **모서리 값이 0 인 것은 `--top-right` 기본값(-6px)과 다르다** — BottomNav 와 같은 까닭이다.
255
+ 통이 넘침을 잘라서(넘침 셈에 그 `hidden` 이 필요하다) 바깥으로 나간 6px 은 그대로 사라진다.
256
+ 안쪽 모서리에 얹으면 52 안에 온전히 들어온다(28~52).
201
257
 
202
- .header {
203
- width: 72px;
204
- transition: width 180ms ease;
205
- overflow: visible;
258
+ 인라인 배지는 흰 테두리를 빼 두는데(흐름 안에 서니 가를 것이 없다), 아이콘 위에 얹히면
259
+ 그 테두리가 있어야 갈린다 — 24px 배지의 테두리는 1px 이다. */
260
+ .header:not(:hover):not(:focus-within) .side-nav__item > .circle-badge,
261
+ .header--force-collapsed .side-nav__item > .circle-badge {
262
+ position: absolute;
263
+ top: 0;
264
+ right: 0;
265
+ border: 1px solid var(--color-pureWhite);
206
266
  }
207
267
 
208
- .header:hover,
209
- .header:focus-within {
210
- width: 220px; /* match desktop */
268
+ /* **발치도 가운데로.** 발치 줄은 좌우 여백이 없어 단추 40px 이 왼쪽에 붙었고, 레일
269
+ 가운데(37)보다 5px 왼쪽에 서서 위 아이콘들과 눈에 띄게 어긋났다(말귀 실측 2026-09-25). */
270
+ .header:not(:hover):not(:focus-within) .side-nav__footer,
271
+ .header--force-collapsed .side-nav__footer {
272
+ align-items: center;
211
273
  }
212
274
 
213
- .header:hover .side-nav__label,
214
- .header:focus-within .side-nav__label {
215
- max-width: 200px;
216
- opacity: 1;
217
- transform: translateX(0);
218
- pointer-events: auto;
275
+ /* **짧은 구분선도 가운데로.** 그 선은 `margin: 4px var(--spacing-m)` 로 서는데, 명시 폭
276
+ (24px)을 가진 플렉스 항목이라 `stretch` 가 안 먹고 **시작 모서리**로 떨어진다. 그래서
277
+ 52px 목록 안에서 중심이 38 이 됐다 — 레일·항목·아이콘 중심은 36 인데 그 선만 2px
278
+ 오른쪽이었다(실측 2026-09-26). `auto` 여백이 남는 자리 가운데로 민다.
279
+ 펼치면 이 규칙이 안 걸리고 16px 으로 돌아가는데, 그 16 은 눈대중이 아니라 **아이콘
280
+ 왼쪽 모서리와 줄을 맞추는 값**이다(220px 레일 실측: 목록 482 · 아이콘 498 · 선 498). */
281
+ .header:not(:hover):not(:focus-within) .side-nav__separator--short,
282
+ .header--force-collapsed .side-nav__separator--short {
283
+ margin-left: auto;
284
+ margin-right: auto;
219
285
  }
220
286
 
221
- /* When JS forces collapsed state (e.g. after navigation), prevent hover from expanding.
222
- !important 불필요 — 선택자 특이도(2클래스, :hover 변형은 3클래스)가 .header:hover(1클래스+가상클래스)보다
223
- 이미 높거나 동일 특이도에서 소스 순서상 뒤에 있어 자연스럽게 우선 적용됨. */
224
- .header.header--force-collapsed {
225
- width: 72px;
226
- }
227
- .header.header--force-collapsed .side-nav__label {
287
+ .header:not(:hover):not(:focus-within) .side-nav__label,
288
+ .header--force-collapsed .side-nav__label {
228
289
  max-width: 0;
229
290
  opacity: 0;
230
291
  transform: translateX(-6px);
231
292
  pointer-events: none;
232
293
  }
233
- .header.header--force-collapsed:hover {
234
- width: 72px;
235
- }
294
+
295
+ /* 접힌 72px 레일에는 로고가 설 자리가 없다 — 심볼로 선다. 펼치면 넓은 화면과 같은
296
+ 얼굴이 되어야 하므로(같은 220px 폭이다) 여기서만 바꾼다. */
297
+ .header:not(:hover):not(:focus-within) .brand-identity__image--symbol,
298
+ .header--force-collapsed .brand-identity__image--symbol { display: block; }
299
+ .header:not(:hover):not(:focus-within) .brand-identity__image--logo,
300
+ .header--force-collapsed .brand-identity__image--logo { display: none; }
236
301
  }
237
302
 
238
303
  /* Mobile: top header + bottom bar */
@@ -279,12 +344,17 @@
279
344
  object-fit: contain;
280
345
  }
281
346
 
347
+ /* **심볼과 로고 중 어느 쪽을 보일지는 여기서 고른다** — 까닭은 `Header.tsx` 의 `BrandIdentity` 에.
348
+ 기본은 로고다(넓은 화면·폰). 심볼은 접힌 레일에서만 선다. */
349
+ .brand-identity__image--symbol { display: none; }
350
+
282
351
  @media (min-width: 1024px) {
283
352
  .brand-identity__root { height: 100px; }
284
353
  }
285
354
 
286
355
  @media (min-width: 768px) and (max-width: 1023px) {
287
356
  .brand-identity__root { height: 100px; }
357
+ /* 심볼이냐 로고냐는 **접혔느냐**로 갈린다 — 그 갈래는 위 레일 블록에 있다(한자리에 모아 둔다). */
288
358
  }
289
359
 
290
360
  @media (max-width: 767px) {
@@ -26,8 +26,14 @@ export function BrandIdentity({ appMode }) {
26
26
  ? (typeof LogoManagerSvg === 'string' && LogoManagerSvg) || resolveAssetUrl('logo/Logo_speechearManager_kr.svg')
27
27
  : (typeof LogoSvg === 'string' && LogoSvg) || resolveAssetUrl('logo/Logo_speechear_kr.svg');
28
28
  const symbol = (typeof SymbolSvg === 'string' && SymbolSvg) || resolveAssetUrl('logo/Symbol_speechear.svg');
29
- const isTablet = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
30
- return (_jsx("div", { className: "brand-identity__root", "aria-label": t('header.biArea', 'BI 영역'), children: isTablet ? (_jsx("img", { src: symbol, alt: t('header.biSymbol', 'BI 심볼'), className: "brand-identity__image" })) : (_jsx("img", { src: logo, alt: isManager ? t('header.logoManager', '말귀매니저 로고') : t('header.logoMalgwi', '말귀 로고'), className: "brand-identity__image" })) }));
29
+ /* **둘을 다 그려 두고 고르는 것은 CSS 가 한다.** 예전에는 여기서 화면 폭만 보고
30
+ (`(min-width: 768px) and (max-width: 1023px)`) 심볼이냐 로고냐를 갈랐는데, **그 폭에서
31
+ 레일은 두 모양이다** — 접힌 72px 과, 호버·포커스로 펼친 220px. 펼친 220 은 넓은 화면과
32
+ 같은 폭인데 BI 만 심볼로 남아, 같은 레일이 같은 폭에서 다른 얼굴을 했다(사장님 지시
33
+ 2026-09-26 — "테블릿에서 호버했을 때 데스크탑과 동일하게"). 펼침은 `:hover`·`:focus-within`
34
+ 이라 **CSS 만 아는 상태**이고, JS 분기로는 `img` 의 `src` 를 바꿀 길이 없다.
35
+ 안 보이는 쪽은 `display: none` 이라 낭독기도 안 읽는다 — `alt` 를 둘 다 둬도 한 번만 읽힌다. */
36
+ return (_jsxs("div", { className: "brand-identity__root", "aria-label": t('header.biArea', 'BI 영역'), children: [_jsx("img", { src: symbol, alt: t('header.biSymbol', 'BI 심볼'), className: "brand-identity__image brand-identity__image--symbol" }), _jsx("img", { src: logo, alt: isManager ? t('header.logoManager', '말귀매니저 로고') : t('header.logoMalgwi', '말귀 로고'), className: "brand-identity__image brand-identity__image--logo" })] }));
31
37
  }
32
38
  /**
33
39
  * 앱 전체 레이아웃의 내비게이션 헤더(사이드바)입니다.
@@ -0,0 +1,12 @@
1
+ /* 보고 있는 한 항목에 하는 일들 — 까닭과 쓰는 법은 `ItemActions.tsx` 의 글에.
2
+
3
+ 제 간격을 스스로 든다. `Popup` 의 `footerStart` 는 이미 같은 간격을 주지만(`--spacing-xs`),
4
+ 바닥 줄을 손수 지어 쓰는 창(`WordTablePopup` 의 `renderFooter`)도 있어서 거기서도 같게
5
+ 서야 한다 — 말귀가 그 자리에 이 네 줄을 그대로 베껴 두고 있었다(2026-09-26). */
6
+ .item-actions {
7
+ display: flex;
8
+ align-items: center;
9
+ gap: var(--spacing-xs);
10
+ /* 좁아져도 잘리는 대신 줄을 바꾼다 — 바닥이 잘려 못 누르던 자리를 겪었다(0.185.0) */
11
+ flex-wrap: wrap;
12
+ }
@@ -0,0 +1,95 @@
1
+ import './ItemActions.css';
2
+ /** @public */
3
+ export interface ItemAction {
4
+ /**
5
+ * 읽어 줄 이름. 아이콘뿐인 단추라 **이름과 말풍선에 같은 말**이 붙는다 —
6
+ * 한쪽만 달면 눈으로 본 말과 낭독기가 읽는 말이 갈린다.
7
+ */
8
+ label: string;
9
+ onClick: () => void;
10
+ }
11
+ /**
12
+ * **한 단추가 뒤집히는 자리**(올리기 ↔ 내리기).
13
+ *
14
+ * 둘을 나란히 세우지 않는 까닭은 자리가 아니라 읽힘이다 — 나란히 두면 **지금 어느 쪽인지**를
15
+ * 화면이 말해 주지 않는다. 아이콘은 그대로 두고 하는 일과 이름만 갈린다.
16
+ * @public
17
+ */
18
+ export interface ItemPublishAction {
19
+ /** 지금 올라가 있나 — 참이면 `unpublish` 쪽이 선다 */
20
+ published: boolean;
21
+ publish: ItemAction;
22
+ unpublish: ItemAction;
23
+ }
24
+ /** @public */
25
+ export interface ItemActionsProps {
26
+ /**
27
+ * 이 창을 닫는 길. **닫고 하는 것**(고치기 · 지우기)이 제 일을 하기 전에 이것을 먼저 부른다.
28
+ *
29
+ * 줄 것이 없어 보여도 비우지 않는다 — 안 닫으면 고칠 창이 보던 창 **위에 겹쳐** 서고,
30
+ * 지운 것을 계속 보고 있는 창이 남는다. 그 사고를 막는 것이 이 줄의 규칙이라
31
+ * 타입으로 못박아 둔다.
32
+ */
33
+ onClose: () => void;
34
+ /** 고치기 — **닫고 한다**(다른 창을 연다) */
35
+ edit?: ItemAction;
36
+ /** 복제 — 안 닫는다(보던 것을 계속 본다) */
37
+ duplicate?: ItemAction;
38
+ /** 발행 ↔ 발행취소 — 안 닫는다. 올리고 내릴 길이 없으면 아예 주지 않는다 */
39
+ publish?: ItemPublishAction;
40
+ /** 링크 복사 — 안 닫는다. 손잡이를 안 준 화면에서는 주지 않는다 */
41
+ copyLink?: ItemAction;
42
+ /** 지우기 — **닫고 한다** */
43
+ remove?: ItemAction;
44
+ className?: string;
45
+ }
46
+ /**
47
+ * **보고 있는 한 항목에 하는 일들** — 창 바닥 왼쪽에 서는 아이콘 단추 줄.
48
+ *
49
+ * 무언가를 **보여 주는 창**이 동시에 **그것을 손대는 자리**이기도 한 곳에 선다. 말귀에 그런
50
+ * 창이 셋 있었고(훈련 묶음 실행 · 단어훈련 카드 실행 · 단어표 미리보기), 세 곳이 같은 다섯
51
+ * 단추를 같은 차례로 세우면서 규칙을 한 벌씩 베껴 적고 있었다(2026-09-26).
52
+ *
53
+ * 벌리는 일은 이미 코어 몫이다(`Popup` 의 `footerStart`/`footerEnd`, 0.190.0). 남아 있던 것은
54
+ * **무엇을 어떤 차례로 세우고 무엇을 닫고 하는가**인데, 그것이 베껴져 있으면 한 곳만 고쳐도
55
+ * 아무 데서도 안 터진다 — 같은 모양의 창들이 조용히 서로 다르게 움직인다. 실제로 **링크 단추의
56
+ * 조건이 세 곳에서 달랐다**(둘은 길이 있을 때만, 하나는 늘 세움).
57
+ *
58
+ * 이 조각이 쥐는 것 —
59
+ * · **차례와 꼴** — 고치기 · 복제 · 발행 · 링크 · 지우기, 모두 `size={40} type="dot"`.
60
+ * · **닫고 할 것과 안 닫고 할 것** — 고치기 · 지우기는 `onClose` 를 먼저 부른다. 둘 다 **다른 창을
61
+ * 여는** 일이라 안 닫으면 창 위에 창이 겹치고, 지운 것을 계속 보고 있게 된다. 복제 · 발행 ·
62
+ * 링크는 안 닫는다 — 보던 것을 그대로 두고 곁에서 일어나는 일이다.
63
+ * · **안 준 것은 안 세운다** — 부르는 쪽이 손으로 `&&` 하던 자리다. 갈라진 그 자리가 여기다.
64
+ * · **뒤집히는 단추는 하나** — `publish.published` 가 어느 쪽인지 고른다.
65
+ *
66
+ * 새 갈래가 필요하면 **여기에 칸을 만든다.** 아무거나 끼울 구멍(`children` 같은 것)을 두지 않는
67
+ * 까닭은, 그 구멍으로 들어온 단추는 닫고 할지 말지를 또 부르는 쪽이 정하게 되어 이 조각이
68
+ * 막으려던 갈라짐이 그대로 돌아오기 때문이다.
69
+ *
70
+ * `Popup` 의 `footerStart` 안에서도, 손수 지은 바닥 줄 안에서도 같게 선다 — 제 간격을 스스로
71
+ * 든다(`--spacing-xs`).
72
+ *
73
+ * 이름표는 부르는 쪽의 말이다 — 아래 예의 글자 자리에 그쪽 사전에서 꺼낸 말을 넣는다.
74
+ * (여기에 `t('…')` 을 적지 않는 까닭은, 소스를 훑는 시험이 **주석 속 예시도 실제로 부르는
75
+ * 열쇠로 읽기** 때문이다. 글이 시험을 깨뜨릴 수 있으면 안 된다.)
76
+ *
77
+ * @example
78
+ * <Popup
79
+ * title={표.name}
80
+ * footerStart={({ closeWithAnimation }) => (
81
+ * <ItemActions
82
+ * onClose={closeWithAnimation}
83
+ * edit={{ label: '수정', onClick: () => openEdit(표) }}
84
+ * duplicate={{ label: '복제', onClick: () => duplicate(표) }}
85
+ * publish={{
86
+ * published: 표.isPublished,
87
+ * publish: { label: '말귀자료실에 올리기', onClick: () => publish(표) },
88
+ * unpublish: { label: '말귀자료실에서 내리기', onClick: () => unpublish(표) },
89
+ * }}
90
+ * copyLink={링크길있나 ? { label: '링크 복사', onClick: () => copyLink(표) } : undefined}
91
+ * remove={{ label: '삭제', onClick: () => askDelete(표) }} />
92
+ * )} />
93
+ * @public
94
+ */
95
+ export default function ItemActions(props: ItemActionsProps): import("react").JSX.Element;
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import Button from './Button';
4
+ import { cx } from '../utils/cx';
5
+ import './ItemActions.css';
6
+ /**
7
+ * **차례 · 아이콘 · 무엇을 닫고 하는가**를 한 표로 둔다.
8
+ *
9
+ * 세 곳에 베껴져 있을 때 실제로 갈라진 것이 이 표의 값들이었다 — 링크 단추의 조건이
10
+ * 둘은 `&&`, 하나는 늘 세움이었다. 한자리에 있으면 갈라질 자리가 없다.
11
+ */
12
+ const 차례 = [
13
+ { 열쇠: 'edit', icon: 'edit-2', 닫고할까: true },
14
+ { 열쇠: 'duplicate', icon: 'copy', 닫고할까: false },
15
+ { 열쇠: 'publish', icon: 'book-marked', 닫고할까: false },
16
+ { 열쇠: 'copyLink', icon: 'link', 닫고할까: false },
17
+ { 열쇠: 'remove', icon: 'trash-2', 닫고할까: true },
18
+ ];
19
+ /**
20
+ * **보고 있는 한 항목에 하는 일들** — 창 바닥 왼쪽에 서는 아이콘 단추 줄.
21
+ *
22
+ * 무언가를 **보여 주는 창**이 동시에 **그것을 손대는 자리**이기도 한 곳에 선다. 말귀에 그런
23
+ * 창이 셋 있었고(훈련 묶음 실행 · 단어훈련 카드 실행 · 단어표 미리보기), 세 곳이 같은 다섯
24
+ * 단추를 같은 차례로 세우면서 규칙을 한 벌씩 베껴 적고 있었다(2026-09-26).
25
+ *
26
+ * 벌리는 일은 이미 코어 몫이다(`Popup` 의 `footerStart`/`footerEnd`, 0.190.0). 남아 있던 것은
27
+ * **무엇을 어떤 차례로 세우고 무엇을 닫고 하는가**인데, 그것이 베껴져 있으면 한 곳만 고쳐도
28
+ * 아무 데서도 안 터진다 — 같은 모양의 창들이 조용히 서로 다르게 움직인다. 실제로 **링크 단추의
29
+ * 조건이 세 곳에서 달랐다**(둘은 길이 있을 때만, 하나는 늘 세움).
30
+ *
31
+ * 이 조각이 쥐는 것 —
32
+ * · **차례와 꼴** — 고치기 · 복제 · 발행 · 링크 · 지우기, 모두 `size={40} type="dot"`.
33
+ * · **닫고 할 것과 안 닫고 할 것** — 고치기 · 지우기는 `onClose` 를 먼저 부른다. 둘 다 **다른 창을
34
+ * 여는** 일이라 안 닫으면 창 위에 창이 겹치고, 지운 것을 계속 보고 있게 된다. 복제 · 발행 ·
35
+ * 링크는 안 닫는다 — 보던 것을 그대로 두고 곁에서 일어나는 일이다.
36
+ * · **안 준 것은 안 세운다** — 부르는 쪽이 손으로 `&&` 하던 자리다. 갈라진 그 자리가 여기다.
37
+ * · **뒤집히는 단추는 하나** — `publish.published` 가 어느 쪽인지 고른다.
38
+ *
39
+ * 새 갈래가 필요하면 **여기에 칸을 만든다.** 아무거나 끼울 구멍(`children` 같은 것)을 두지 않는
40
+ * 까닭은, 그 구멍으로 들어온 단추는 닫고 할지 말지를 또 부르는 쪽이 정하게 되어 이 조각이
41
+ * 막으려던 갈라짐이 그대로 돌아오기 때문이다.
42
+ *
43
+ * `Popup` 의 `footerStart` 안에서도, 손수 지은 바닥 줄 안에서도 같게 선다 — 제 간격을 스스로
44
+ * 든다(`--spacing-xs`).
45
+ *
46
+ * 이름표는 부르는 쪽의 말이다 — 아래 예의 글자 자리에 그쪽 사전에서 꺼낸 말을 넣는다.
47
+ * (여기에 `t('…')` 을 적지 않는 까닭은, 소스를 훑는 시험이 **주석 속 예시도 실제로 부르는
48
+ * 열쇠로 읽기** 때문이다. 글이 시험을 깨뜨릴 수 있으면 안 된다.)
49
+ *
50
+ * @example
51
+ * <Popup
52
+ * title={표.name}
53
+ * footerStart={({ closeWithAnimation }) => (
54
+ * <ItemActions
55
+ * onClose={closeWithAnimation}
56
+ * edit={{ label: '수정', onClick: () => openEdit(표) }}
57
+ * duplicate={{ label: '복제', onClick: () => duplicate(표) }}
58
+ * publish={{
59
+ * published: 표.isPublished,
60
+ * publish: { label: '말귀자료실에 올리기', onClick: () => publish(표) },
61
+ * unpublish: { label: '말귀자료실에서 내리기', onClick: () => unpublish(표) },
62
+ * }}
63
+ * copyLink={링크길있나 ? { label: '링크 복사', onClick: () => copyLink(표) } : undefined}
64
+ * remove={{ label: '삭제', onClick: () => askDelete(표) }} />
65
+ * )} />
66
+ * @public
67
+ */
68
+ export default function ItemActions(props) {
69
+ const { onClose, className } = props;
70
+ return (_jsx("div", { className: cx('item-actions', className), children: 차례.map(({ 열쇠, icon, 닫고할까 }) => {
71
+ const 받은것 = props[열쇠];
72
+ if (!받은것)
73
+ return null;
74
+ const 할일 = 열쇠 === 'publish'
75
+ ? (받은것.published
76
+ ? 받은것.unpublish
77
+ : 받은것.publish)
78
+ : 받은것;
79
+ return (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: icon, ariaLabel: 할일.label, showTooltipInfo: 할일.label, onClick: () => { if (닫고할까)
80
+ onClose(); 할일.onClick(); } }, 열쇠));
81
+ }) }));
82
+ }
@@ -0,0 +1,48 @@
1
+ import type { ReactNode } from 'react';
2
+ /** @public */
3
+ export interface DiscardGuardLabels {
4
+ title?: string;
5
+ message?: string;
6
+ confirmLabel?: string;
7
+ }
8
+ /** @public */
9
+ export interface DiscardGuard {
10
+ /** `Popup` 의 `beforeClose` 로 넘긴다 — `false` 면 안 닫힌다 */
11
+ beforeClose: () => boolean;
12
+ /** 물음창. JSX 어디에 두든 된다(아래 글 참고) */
13
+ discardPopup: ReactNode;
14
+ /** **다음 한 번은 안 묻는다** — 저장이 끝나고 스스로 닫을 때 부른다 */
15
+ allowClose: () => void;
16
+ }
17
+ /**
18
+ * **쓰던 것을 버리고 닫으려 할 때 한 번 더 묻는다.**
19
+ *
20
+ * 코어 0.128.0 부터 창은 어두운 바탕을 눌러도 닫힌다(사장님 지시 2026-09-14 "기본값으로").
21
+ * 긴 글을 받는 창에서 바깥을 한 번 잘못 누르면 쓰던 것이 통째로 사라진다 — 그 자리를 막는다.
22
+ *
23
+ * 말귀 회원앱과 어드민에 한 벌씩 있었고(2026-09-27) **어드민 쪽이 윗집합**이었다. 갈라져
24
+ * 있던 탓에 어드민에서 찾은 고침(`allowClose`)이 회원앱으로 건너오지 못했다 — 회원앱
25
+ * 고객 추가 창이 **저장을 끝내고도 "저장했는데 버릴까요?" 를 물었다.** 여기 한 벌로 둔다.
26
+ *
27
+ * **`allowClose` 가 그 고침이다.** 저장이 끝나고 창이 스스로 닫을 때(`closeWithAnimation`)도
28
+ * 그 길은 `beforeClose` 를 거치는데, 폼은 방금 채워 달라진 채라 "버릴까요" 가 뜬다. 저장에
29
+ * 성공한 직후 `allowClose()` 를 부르면 **다음 한 번만** 안 묻고 닫는다. 같은 틱에 닫으므로
30
+ * 상태가 아니라 ref 로 든다 — 상태로 들면 다시 그려지기 전에 닫혀 안 읽힌다.
31
+ *
32
+ * **물음창은 JSX 어디에 두든 된다.** 창은 `document.body` 로 옮겨 붙고(`createPortal`)
33
+ * 그 차례는 코드 자리가 아니라 **뜬 시각**으로 정해지므로, 나중에 뜨는 이 물음창이 늘 위다.
34
+ *
35
+ * 문구는 `labels` 로 바꾼다 — 안 주면 사전(`common.discardConfirm.*`)을 따르고, 사전이
36
+ * 없으면 한국어 기본값으로 그린다(번역을 안 쓰는 소비자도 그대로 읽힌다).
37
+ *
38
+ * ```tsx
39
+ * const { beforeClose, discardPopup, allowClose } = useDiscardGuard(달라졌나, 닫기)
40
+ * const 저장 = async () => { await 보내기(); allowClose(); closeWithAnimation() }
41
+ * return <Popup title={제목} beforeClose={beforeClose} …>{…}{discardPopup}</Popup>
42
+ * ```
43
+ *
44
+ * @param isDirty 지금 닫으면 잃을 것이 있는가
45
+ * @param onClose 버리기로 했을 때 실제로 닫는 일
46
+ * @public
47
+ */
48
+ export declare function useDiscardGuard(isDirty: boolean, onClose: () => void, labels?: DiscardGuardLabels): DiscardGuard;
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useCallback, useRef, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import ConfirmPopup from './ConfirmPopup';
6
+ /**
7
+ * **쓰던 것을 버리고 닫으려 할 때 한 번 더 묻는다.**
8
+ *
9
+ * 코어 0.128.0 부터 창은 어두운 바탕을 눌러도 닫힌다(사장님 지시 2026-09-14 "기본값으로").
10
+ * 긴 글을 받는 창에서 바깥을 한 번 잘못 누르면 쓰던 것이 통째로 사라진다 — 그 자리를 막는다.
11
+ *
12
+ * 말귀 회원앱과 어드민에 한 벌씩 있었고(2026-09-27) **어드민 쪽이 윗집합**이었다. 갈라져
13
+ * 있던 탓에 어드민에서 찾은 고침(`allowClose`)이 회원앱으로 건너오지 못했다 — 회원앱
14
+ * 고객 추가 창이 **저장을 끝내고도 "저장했는데 버릴까요?" 를 물었다.** 여기 한 벌로 둔다.
15
+ *
16
+ * **`allowClose` 가 그 고침이다.** 저장이 끝나고 창이 스스로 닫을 때(`closeWithAnimation`)도
17
+ * 그 길은 `beforeClose` 를 거치는데, 폼은 방금 채워 달라진 채라 "버릴까요" 가 뜬다. 저장에
18
+ * 성공한 직후 `allowClose()` 를 부르면 **다음 한 번만** 안 묻고 닫는다. 같은 틱에 닫으므로
19
+ * 상태가 아니라 ref 로 든다 — 상태로 들면 다시 그려지기 전에 닫혀 안 읽힌다.
20
+ *
21
+ * **물음창은 JSX 어디에 두든 된다.** 창은 `document.body` 로 옮겨 붙고(`createPortal`)
22
+ * 그 차례는 코드 자리가 아니라 **뜬 시각**으로 정해지므로, 나중에 뜨는 이 물음창이 늘 위다.
23
+ *
24
+ * 문구는 `labels` 로 바꾼다 — 안 주면 사전(`common.discardConfirm.*`)을 따르고, 사전이
25
+ * 없으면 한국어 기본값으로 그린다(번역을 안 쓰는 소비자도 그대로 읽힌다).
26
+ *
27
+ * ```tsx
28
+ * const { beforeClose, discardPopup, allowClose } = useDiscardGuard(달라졌나, 닫기)
29
+ * const 저장 = async () => { await 보내기(); allowClose(); closeWithAnimation() }
30
+ * return <Popup title={제목} beforeClose={beforeClose} …>{…}{discardPopup}</Popup>
31
+ * ```
32
+ *
33
+ * @param isDirty 지금 닫으면 잃을 것이 있는가
34
+ * @param onClose 버리기로 했을 때 실제로 닫는 일
35
+ * @public
36
+ */
37
+ export function useDiscardGuard(isDirty, onClose, labels = {}) {
38
+ const { t } = useTranslation();
39
+ const [confirming, setConfirming] = useState(false);
40
+ const 통과 = useRef(false);
41
+ const allowClose = useCallback(() => { 통과.current = true; }, []);
42
+ const beforeClose = useCallback(() => {
43
+ /* 한 번 쓰고 되돌린다 — 안 되돌리면 그 뒤로 영영 안 묻는다 */
44
+ if (통과.current) {
45
+ 통과.current = false;
46
+ return true;
47
+ }
48
+ if (isDirty) {
49
+ setConfirming(true);
50
+ return false;
51
+ }
52
+ return true;
53
+ }, [isDirty]);
54
+ const discardPopup = confirming ? (_jsx(ConfirmPopup, { title: labels.title ?? t('common.discardConfirm.title', '입력을 취소할까요?'), message: labels.message ?? t('common.discardConfirm.body', '저장하지 않으면 지금까지 입력한 내용이 사라집니다.'), confirmLabel: labels.confirmLabel ?? t('common.discardConfirm.confirm', '입력 취소'), confirmVariant: "danger", onConfirm: () => { setConfirming(false); onClose(); }, onClose: () => setConfirming(false) })) : null;
55
+ return { beforeClose, discardPopup, allowClose };
56
+ }
@@ -140,10 +140,22 @@
140
140
  }
141
141
 
142
142
  /* ── footer: 항상 list 아래에 위치, 줄어들지 않음 ─────────────────────────── */
143
+ /* **배지를 얹으려고 알림 단추에만 한 겹 두른 것**(`position: relative` 가 배지의 기준이다).
144
+ 그 한 겹 때문에 이 하나만 발치 줄에서 다르게 놓였다.
145
+
146
+ `align-self: flex-start` 였다 — 접힌 레일에서 발치 줄은 `align-items: center` 인데 이 선언이
147
+ 그것을 덮어 **혼자 왼쪽에 붙었다**. 단추가 동그라미라 다섯이 한 줄로 서면 하나 틀어진 것이
148
+ 바로 보인다(말귀 실측 2026-09-26: 넷은 가운데 36, 이것만 30 — 6px. 놀이터는 단추가 한 급
149
+ 작아져 4px 이지만 같은 일이다. 어긋남은 `(줄 폭 − 단추 폭) / 2` 다).
150
+
151
+ **`align-self` 만 걷으면 안 된다.** 펼친 줄은 `align-items: stretch` 라 이 span 이 늘어난다 —
152
+ 1440px 에서 **194px** 이 됐다(실측). 단추는 48 그대로라 눈에는 같지만, 줄 전체가 알림 단추의
153
+ 몸이 되어 빈 자리에서도 호버·클릭이 걸린다. `width: fit-content` 는 definite width 라
154
+ stretch 를 막는다. 둘을 함께 둬야 두 폭 모두 제자리다(768·1440 재확인). */
143
155
  .side-nav__notification-wrap {
144
156
  position: relative;
145
157
  display: inline-flex;
146
- align-self: flex-start;
158
+ width: fit-content;
147
159
  }
148
160
 
149
161
  .side-nav__footer {
@@ -134,6 +134,8 @@ export { default as Checkbox } from './components/Checkbox';
134
134
  export type { CheckboxProps } from './components/Checkbox';
135
135
  export { default as BulkBar } from './components/BulkBar';
136
136
  export type { BulkBarProps, BulkAction } from './components/BulkBar';
137
+ export { default as ItemActions } from './components/ItemActions';
138
+ export type { ItemActionsProps, ItemAction, ItemPublishAction } from './components/ItemActions';
137
139
  export { default as DatePicker } from './components/DatePicker';
138
140
  export type { DatePickerProps } from './components/DatePicker';
139
141
  export { useCalendarMonth } from './components/useCalendarMonth';
@@ -236,6 +238,8 @@ export { default as ListPopup } from './components/Popup/ListPopup';
236
238
  export type { PopupListProps, PopupListColumn } from './components/Popup/ListPopup';
237
239
  export { default as ConfirmPopup } from './components/Popup/ConfirmPopup';
238
240
  export type { ConfirmPopupProps } from './components/Popup/ConfirmPopup';
241
+ export { useDiscardGuard } from './components/Popup/useDiscardGuard';
242
+ export type { DiscardGuard, DiscardGuardLabels } from './components/Popup/useDiscardGuard';
239
243
  export { default as CouponInputPopup } from './components/Popup/CouponInputPopup';
240
244
  export type { CouponInputPopupProps } from './components/Popup/CouponInputPopup';
241
245
  export { default as MenuTooltip } from './components/Tooltip/MenuTooltip';