@bigtablet/design-system 3.17.2 → 3.18.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/dist/index.js CHANGED
@@ -75,6 +75,46 @@ var PREV_GUTTER = "originalScrollbarGutter";
75
75
  var PREV_PADDING_RIGHT = "originalPaddingRight";
76
76
  var PREV_SCROLLBAR_WIDTH_VAR = "originalScrollbarWidthVar";
77
77
  var SCROLLBAR_WIDTH_VAR = "--bt-scrollbar-width";
78
+ var LOCKED_ATTR = "data-bt-scroll-locked";
79
+ var DIM_VAR = "--bt-color-bg-overlay";
80
+ var DIM_FALLBACK = "rgba(0, 0, 0, 0.5)";
81
+ var PREV_BACKGROUND_COLOR = "originalBackgroundColor";
82
+ var WHITE = [255, 255, 255, 1];
83
+ var parseRgb = (value) => {
84
+ const match = value.trim().match(/^rgba?\(([^)]+)\)$/i);
85
+ if (!match) return null;
86
+ const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number);
87
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
88
+ return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1];
89
+ };
90
+ var normalizeColor = (value) => {
91
+ const direct = parseRgb(value);
92
+ if (direct) return direct;
93
+ const probe = document.createElement("div");
94
+ probe.style.cssText = "position:fixed;top:0;left:0;width:0;height:0;visibility:hidden";
95
+ probe.style.color = value;
96
+ document.documentElement.appendChild(probe);
97
+ const computed = window.getComputedStyle(probe).color;
98
+ probe.remove();
99
+ return parseRgb(computed);
100
+ };
101
+ var over = (top, bottom) => [
102
+ Math.round(top[0] * top[3] + bottom[0] * (1 - top[3])),
103
+ Math.round(top[1] * top[3] + bottom[1] * (1 - top[3])),
104
+ Math.round(top[2] * top[3] + bottom[2] * (1 - top[3])),
105
+ 1
106
+ ];
107
+ var measureCanvasColors = (html, body) => {
108
+ const raw = window.getComputedStyle(html).getPropertyValue(DIM_VAR).trim() || DIM_FALLBACK;
109
+ const dim = normalizeColor(raw);
110
+ if (!dim || dim[3] <= 0) return null;
111
+ const candidates = [
112
+ parseRgb(window.getComputedStyle(html).backgroundColor),
113
+ parseRgb(window.getComputedStyle(body).backgroundColor)
114
+ ];
115
+ const base = candidates.find((color) => color !== null && color[3] > 0) ?? WHITE;
116
+ return [dim, over(base, WHITE)];
117
+ };
78
118
  var measureViewportInset = () => {
79
119
  const probe = document.createElement("div");
80
120
  probe.style.cssText = "position:fixed;top:0;left:0;right:0;height:0;visibility:hidden;pointer-events:none";
@@ -84,6 +124,33 @@ var measureViewportInset = () => {
84
124
  if (width <= 0) return 0;
85
125
  return Math.max(0, window.innerWidth - width);
86
126
  };
127
+ var dimProgress = /* @__PURE__ */ new Map();
128
+ var canvasBase = null;
129
+ var canvasDim = null;
130
+ var combinedDimAlpha = (alpha) => {
131
+ if (dimProgress.size === 0) return alpha;
132
+ let transmitted = 1;
133
+ for (const progress of dimProgress.values()) {
134
+ transmitted *= 1 - alpha * progress;
135
+ }
136
+ return 1 - transmitted;
137
+ };
138
+ var paintCanvas = (html) => {
139
+ if (!canvasBase || !canvasDim) return;
140
+ const alpha = combinedDimAlpha(canvasDim[3]);
141
+ const [r, g, b] = over([canvasDim[0], canvasDim[1], canvasDim[2], alpha], canvasBase);
142
+ html.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
143
+ };
144
+ function reportOverlayDim(owner, progress) {
145
+ if (typeof document === "undefined") return;
146
+ dimProgress.set(owner, Math.min(1, Math.max(0, progress)));
147
+ paintCanvas(document.documentElement);
148
+ }
149
+ function unregisterOverlayDim(owner) {
150
+ if (typeof document === "undefined") return;
151
+ if (!dimProgress.delete(owner)) return;
152
+ paintCanvas(document.documentElement);
153
+ }
87
154
  function lockBodyScroll() {
88
155
  if (typeof document === "undefined") return;
89
156
  const body = document.body;
@@ -98,6 +165,7 @@ function lockBodyScroll() {
98
165
  body.dataset[PREV_GUTTER] = html.style.scrollbarGutter;
99
166
  body.dataset[PREV_PADDING_RIGHT] = body.style.paddingRight;
100
167
  body.dataset[PREV_SCROLLBAR_WIDTH_VAR] = html.style.getPropertyValue(SCROLLBAR_WIDTH_VAR);
168
+ body.dataset[PREV_BACKGROUND_COLOR] = html.style.backgroundColor;
101
169
  if (scrollbarWidth > 0) {
102
170
  html.style.setProperty(SCROLLBAR_WIDTH_VAR, `${scrollbarWidth}px`);
103
171
  }
@@ -109,6 +177,14 @@ function lockBodyScroll() {
109
177
  body.style.paddingRight = `${current + scrollbarWidth}px`;
110
178
  }
111
179
  }
180
+ if (scrollbarWidth > 0 && canReserveGutter) {
181
+ const measured = measureCanvasColors(html, body);
182
+ if (measured) {
183
+ [canvasDim, canvasBase] = measured;
184
+ paintCanvas(html);
185
+ }
186
+ }
187
+ html.setAttribute(LOCKED_ATTR, "");
112
188
  body.style.overflow = "hidden";
113
189
  }
114
190
  body.dataset[COUNTER] = String(open + 1);
@@ -122,6 +198,8 @@ function unlockBodyScroll() {
122
198
  body.style.overflow = body.dataset[PREV_OVERFLOW] || "";
123
199
  body.style.paddingRight = body.dataset[PREV_PADDING_RIGHT] || "";
124
200
  html.style.scrollbarGutter = body.dataset[PREV_GUTTER] || "";
201
+ html.style.backgroundColor = body.dataset[PREV_BACKGROUND_COLOR] || "";
202
+ html.removeAttribute(LOCKED_ATTR);
125
203
  const prevVar = body.dataset[PREV_SCROLLBAR_WIDTH_VAR];
126
204
  if (prevVar) {
127
205
  html.style.setProperty(SCROLLBAR_WIDTH_VAR, prevVar);
@@ -133,6 +211,10 @@ function unlockBodyScroll() {
133
211
  delete body.dataset[PREV_GUTTER];
134
212
  delete body.dataset[PREV_PADDING_RIGHT];
135
213
  delete body.dataset[PREV_SCROLLBAR_WIDTH_VAR];
214
+ delete body.dataset[PREV_BACKGROUND_COLOR];
215
+ dimProgress.clear();
216
+ canvasBase = null;
217
+ canvasDim = null;
136
218
  } else {
137
219
  body.dataset[COUNTER] = String(remaining);
138
220
  }
@@ -198,15 +280,16 @@ function computeAnchoredPosition(anchor, floating, viewport, options) {
198
280
  if (!fitsMainAxis(side, anchor, sized, viewport, gap, padding) && fitsMainAxis(OPPOSITE[side], anchor, sized, viewport, gap, padding)) {
199
281
  side = OPPOSITE[side];
200
282
  }
283
+ const align = options.align ?? "center";
201
284
  let x;
202
285
  let y;
203
286
  if (isVertical(side)) {
204
287
  y = mainAxisStart(side, anchor, sized, gap);
205
- x = anchor.left + anchor.width / 2 - sized.width / 2;
288
+ x = align === "start" ? anchor.left : align === "end" ? anchor.left + anchor.width - sized.width : anchor.left + anchor.width / 2 - sized.width / 2;
206
289
  x = clamp(x, padding, viewport.width - padding - sized.width);
207
290
  } else {
208
291
  x = mainAxisStart(side, anchor, sized, gap);
209
- y = anchor.top + anchor.height / 2 - sized.height / 2;
292
+ y = align === "start" ? anchor.top : align === "end" ? anchor.top + anchor.height - sized.height : anchor.top + anchor.height / 2 - sized.height / 2;
210
293
  y = clamp(y, padding, viewport.height - padding - sized.height);
211
294
  }
212
295
  return { x, y, placement: side, maxWidth };
@@ -216,6 +299,7 @@ function useAnchoredPosition({
216
299
  anchorRef,
217
300
  floatingRef,
218
301
  placement,
302
+ align,
219
303
  gap,
220
304
  padding
221
305
  }) {
@@ -224,7 +308,8 @@ function useAnchoredPosition({
224
308
  y: 0,
225
309
  placement,
226
310
  maxWidth: 0,
227
- ready: false
311
+ ready: false,
312
+ anchorWidth: 0
228
313
  });
229
314
  useSafeLayoutEffect(() => {
230
315
  if (!open) {
@@ -241,9 +326,9 @@ function useAnchoredPosition({
241
326
  { top: a.top, left: a.left, width: a.width, height: a.height },
242
327
  { width: f.width, height: f.height },
243
328
  { width: window.innerWidth, height: window.innerHeight },
244
- { placement, gap, padding }
329
+ { placement, align, gap, padding }
245
330
  );
246
- setState({ ...result, ready: true });
331
+ setState({ ...result, ready: true, anchorWidth: a.width });
247
332
  };
248
333
  let frame = 0;
249
334
  const schedule = () => {
@@ -265,7 +350,7 @@ function useAnchoredPosition({
265
350
  window.removeEventListener("resize", schedule);
266
351
  observer?.disconnect();
267
352
  };
268
- }, [open, placement, gap, padding, anchorRef, floatingRef]);
353
+ }, [open, placement, align, gap, padding, anchorRef, floatingRef]);
269
354
  return state;
270
355
  }
271
356
  var SKIP_AUTOFOCUS_ATTR = "data-focus-trap-skip-autofocus";
@@ -374,7 +459,8 @@ function useSpringHover({
374
459
  const reduced = useReducedMotion();
375
460
  const style = useSpring({
376
461
  transform: hovered ? `translateY(${lift}px) scale(${scale})` : "translateY(0px) scale(1)",
377
- // reduced-motion: lift/scale 모션 제거 (WCAG 2.3.3)
462
+ // reduced-motion: 보간을 없애 움직임을 지운다. 최종 상태(lift/scale)는 남는다 - 강조가
463
+ // 사라지면 hover/focus 를 알 수 없다. WCAG 2.3.3 이 막는 것은 모션이다.
378
464
  immediate: reduced,
379
465
  config: { tension: 320, friction: 22 }
380
466
  });
@@ -389,7 +475,8 @@ function useSpringHover({
389
475
  function useSpringPresence({
390
476
  visible,
391
477
  from = "translateY(8px)",
392
- onExitComplete
478
+ onExitComplete,
479
+ onProgress
393
480
  }) {
394
481
  const reduced = useReducedMotion();
395
482
  return useSpring({
@@ -410,6 +497,7 @@ function useSpringPresence({
410
497
  clamp: !visible
411
498
  // 사라질 땐 진동 없이 빠르게
412
499
  },
500
+ onChange: (result) => onProgress?.(Number(result.value.opacity)),
413
501
  onRest: (result) => {
414
502
  if (!visible && result.finished && onExitComplete) {
415
503
  onExitComplete();
@@ -417,7 +505,8 @@ function useSpringPresence({
417
505
  }
418
506
  });
419
507
  }
420
- var MIN_SPACE_BELOW = 120;
508
+ var LIST_GAP = 4;
509
+ var LIST_PADDING = 8;
421
510
  function useListboxPopup({
422
511
  items,
423
512
  onCommit,
@@ -427,17 +516,19 @@ function useListboxPopup({
427
516
  }) {
428
517
  const [isOpen, setIsOpen] = useState(false);
429
518
  const [activeIndex, setActiveIndex] = useState(-1);
430
- const [dropUp, setDropUp] = useState(false);
431
519
  const wrapperRef = useRef(null);
432
520
  const triggerRef = useRef(null);
433
521
  const listRef = useRef(null);
522
+ const panelRef = useRef(null);
434
523
  const close = useCallback(() => {
435
524
  setIsOpen(false);
436
525
  if (returnFocusOnClose) triggerRef.current?.focus();
437
526
  }, [returnFocusOnClose]);
438
527
  useEffect(() => {
439
528
  const handleOutsideClick = (event) => {
440
- if (!wrapperRef.current?.contains(event.target)) setIsOpen(false);
529
+ const target = event.target;
530
+ if (wrapperRef.current?.contains(target) || panelRef.current?.contains(target)) return;
531
+ setIsOpen(false);
441
532
  };
442
533
  document.addEventListener("mousedown", handleOutsideClick);
443
534
  return () => document.removeEventListener("mousedown", handleOutsideClick);
@@ -556,22 +647,33 @@ function useListboxPopup({
556
647
  const option = list.querySelectorAll('[role="option"]')[activeIndex];
557
648
  option?.scrollIntoView?.({ block: "nearest" });
558
649
  }, [isOpen, activeIndex, items]);
559
- useEffect(() => {
560
- if (!isOpen || !triggerRef.current) return;
561
- const rect = triggerRef.current.getBoundingClientRect();
562
- const spaceBelow = window.innerHeight - rect.bottom;
563
- const spaceAbove = rect.top;
564
- setDropUp(spaceBelow < MIN_SPACE_BELOW && spaceAbove > spaceBelow);
565
- }, [isOpen]);
650
+ const anchored = useAnchoredPosition({
651
+ open: isOpen,
652
+ anchorRef: wrapperRef,
653
+ floatingRef: panelRef,
654
+ placement: "bottom",
655
+ // 트리거 왼쪽 변에 맞춘다 - 목록이 컨트롤의 연장으로 읽혀야 한다.
656
+ align: "start",
657
+ gap: LIST_GAP,
658
+ padding: LIST_PADDING
659
+ });
566
660
  return {
567
661
  isOpen,
568
662
  setIsOpen,
569
- dropUp,
663
+ dropUp: anchored.placement === "top",
664
+ position: {
665
+ x: anchored.x,
666
+ y: anchored.y,
667
+ width: anchored.anchorWidth,
668
+ maxWidth: anchored.maxWidth,
669
+ ready: anchored.ready
670
+ },
570
671
  activeIndex,
571
672
  setActiveIndex,
572
673
  wrapperRef,
573
674
  triggerRef,
574
675
  listRef,
676
+ panelRef,
575
677
  close,
576
678
  moveActive,
577
679
  commitActive,
@@ -1119,25 +1221,46 @@ var Breadcrumb = ({
1119
1221
  const sep = separator ?? /* @__PURE__ */ jsx(ChevronRight, { size: iconSize.xs, "aria-hidden": "true" });
1120
1222
  return /* @__PURE__ */ jsx("nav", { "aria-label": navLabel, className: cn("breadcrumb", className), ...props, children: /* @__PURE__ */ jsx("ol", { className: "breadcrumb_list", children: items.map((item, idx) => {
1121
1223
  const isLast = idx === items.length - 1;
1224
+ const Tag = item.as ?? "a";
1122
1225
  return (
1123
1226
  // biome-ignore lint/suspicious/noArrayIndexKey: breadcrumb items have stable order
1124
1227
  /* @__PURE__ */ jsxs("li", { className: "breadcrumb_item", children: [
1125
- isLast ? /* @__PURE__ */ jsx("span", { className: "breadcrumb_current", "aria-current": "page", children: item.label }) : item.href ? /* @__PURE__ */ jsx("a", { className: "breadcrumb_link", href: item.href, onClick: item.onClick, children: item.label }) : /* @__PURE__ */ jsx("button", { type: "button", className: "breadcrumb_link", onClick: item.onClick, children: item.label }),
1228
+ isLast ? /* @__PURE__ */ jsx("span", { className: "breadcrumb_current", "aria-current": "page", children: item.label }) : item.href ? (
1229
+ // `href`·`className`·`onClick` 을 그대로 넘긴다 - 라우터 `Link` 가 수정자
1230
+ // 클릭·프리페치를 자기 방식대로 처리한다.
1231
+ /* @__PURE__ */ jsx(Tag, { className: "breadcrumb_link", href: item.href, onClick: item.onClick, children: item.label })
1232
+ ) : /* @__PURE__ */ jsx("button", { type: "button", className: "breadcrumb_link", onClick: item.onClick, children: item.label }),
1126
1233
  !isLast && /* @__PURE__ */ jsx("span", { className: "breadcrumb_separator", "aria-hidden": "true", children: sep })
1127
1234
  ] }, idx)
1128
1235
  );
1129
1236
  }) }) });
1130
1237
  };
1238
+ var MENU_GAP = 4;
1131
1239
  var Menu = ({ items, trigger, align = "start" }) => {
1132
1240
  const [open, setOpen] = React11.useState(false);
1133
1241
  const wrapperRef = React11.useRef(null);
1242
+ const menuRef = React11.useRef(null);
1134
1243
  const itemRefs = React11.useRef([]);
1135
1244
  const menuId = React11.useId();
1136
- const style = useSpringPresence({ visible: open, from: "translateY(-4px)" });
1245
+ const pos = useAnchoredPosition({
1246
+ open,
1247
+ anchorRef: wrapperRef,
1248
+ floatingRef: menuRef,
1249
+ placement: "bottom",
1250
+ align,
1251
+ gap: MENU_GAP,
1252
+ padding: 8
1253
+ });
1254
+ const style = useSpringPresence({
1255
+ visible: open,
1256
+ from: pos.placement === "top" ? `translateY(${MENU_GAP}px)` : `translateY(-${MENU_GAP}px)`
1257
+ });
1137
1258
  React11.useEffect(() => {
1138
1259
  if (!open) return;
1139
1260
  const handleClick = (e) => {
1140
- if (!wrapperRef.current?.contains(e.target)) setOpen(false);
1261
+ const target = e.target;
1262
+ if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) return;
1263
+ setOpen(false);
1141
1264
  };
1142
1265
  const handleEsc = (e) => {
1143
1266
  if (e.key === "Escape") setOpen(false);
@@ -1160,8 +1283,8 @@ var Menu = ({ items, trigger, align = "start" }) => {
1160
1283
  if (enabled.length === 0) return;
1161
1284
  if (dir === "first") return void itemRefs.current[enabled[0]]?.focus();
1162
1285
  if (dir === "last") return void itemRefs.current[enabled[enabled.length - 1]]?.focus();
1163
- const pos = enabled.findIndex((i) => itemRefs.current[i] === document.activeElement);
1164
- const next = pos < 0 ? dir === 1 ? 0 : enabled.length - 1 : (pos + dir + enabled.length) % enabled.length;
1286
+ const pos2 = enabled.findIndex((i) => itemRefs.current[i] === document.activeElement);
1287
+ const next = pos2 < 0 ? dir === 1 ? 0 : enabled.length - 1 : (pos2 + dir + enabled.length) % enabled.length;
1165
1288
  itemRefs.current[enabled[next]]?.focus();
1166
1289
  };
1167
1290
  const handleMenuKeyDown = (e) => {
@@ -1191,6 +1314,7 @@ var Menu = ({ items, trigger, align = "start" }) => {
1191
1314
  wrapperRef.current?.querySelector("[aria-haspopup]")?.focus();
1192
1315
  break;
1193
1316
  case "Tab":
1317
+ wrapperRef.current?.querySelector("[aria-haspopup]")?.focus();
1194
1318
  setOpen(false);
1195
1319
  break;
1196
1320
  }
@@ -1206,42 +1330,52 @@ var Menu = ({ items, trigger, align = "start" }) => {
1206
1330
  );
1207
1331
  return /* @__PURE__ */ jsxs("span", { className: "menu_wrapper", ref: wrapperRef, children: [
1208
1332
  triggerWithProps,
1209
- open && /* @__PURE__ */ jsx(
1210
- animated.div,
1211
- {
1212
- id: menuId,
1213
- role: "menu",
1214
- style,
1215
- className: cn("menu", `menu_align_${align}`),
1216
- onKeyDown: handleMenuKeyDown,
1217
- children: items.map((item, index) => /* @__PURE__ */ jsxs(
1218
- "button",
1219
- {
1220
- ref: (el) => {
1221
- itemRefs.current[index] = el;
1222
- },
1223
- type: "button",
1224
- role: "menuitem",
1225
- tabIndex: -1,
1226
- disabled: item.disabled,
1227
- className: cn(
1228
- "menu_item",
1229
- item.destructive && "menu_item_destructive",
1230
- item.disabled && "menu_item_disabled"
1231
- ),
1232
- onClick: () => {
1233
- if (item.disabled) return;
1234
- item.onSelect?.();
1235
- setOpen(false);
1236
- },
1237
- children: [
1238
- item.icon && /* @__PURE__ */ jsx("span", { className: "menu_item_icon", "aria-hidden": "true", children: item.icon }),
1239
- /* @__PURE__ */ jsx("span", { className: "menu_item_label", children: item.label })
1240
- ]
1333
+ open && typeof document !== "undefined" && createPortal(
1334
+ /* @__PURE__ */ jsx(
1335
+ animated.div,
1336
+ {
1337
+ id: menuId,
1338
+ ref: menuRef,
1339
+ role: "menu",
1340
+ style: {
1341
+ ...style,
1342
+ position: "fixed",
1343
+ left: pos.x,
1344
+ top: pos.y,
1345
+ visibility: pos.ready ? void 0 : "hidden"
1241
1346
  },
1242
- item.key
1243
- ))
1244
- }
1347
+ className: cn("menu", `menu_align_${align}`),
1348
+ onKeyDown: handleMenuKeyDown,
1349
+ children: items.map((item, index) => /* @__PURE__ */ jsxs(
1350
+ "button",
1351
+ {
1352
+ ref: (el) => {
1353
+ itemRefs.current[index] = el;
1354
+ },
1355
+ type: "button",
1356
+ role: "menuitem",
1357
+ tabIndex: -1,
1358
+ disabled: item.disabled,
1359
+ className: cn(
1360
+ "menu_item",
1361
+ item.destructive && "menu_item_destructive",
1362
+ item.disabled && "menu_item_disabled"
1363
+ ),
1364
+ onClick: () => {
1365
+ if (item.disabled) return;
1366
+ item.onSelect?.();
1367
+ setOpen(false);
1368
+ },
1369
+ children: [
1370
+ item.icon && /* @__PURE__ */ jsx("span", { className: "menu_item_icon", "aria-hidden": "true", children: item.icon }),
1371
+ /* @__PURE__ */ jsx("span", { className: "menu_item_label", children: item.label })
1372
+ ]
1373
+ },
1374
+ item.key
1375
+ ))
1376
+ }
1377
+ ),
1378
+ document.body
1245
1379
  )
1246
1380
  ] });
1247
1381
  };
@@ -1437,13 +1571,16 @@ var LocaleSwitcher = ({ locale }) => {
1437
1571
  ) }, opt.value)) })
1438
1572
  ] });
1439
1573
  };
1440
- var NavLink = ({ active, className, children, ...props }) => {
1574
+ var NavLink = (props) => {
1575
+ const { active, className, children, as, ref, ...rest } = props;
1576
+ const Tag = as ?? "a";
1441
1577
  return /* @__PURE__ */ jsx(
1442
- "a",
1578
+ Tag,
1443
1579
  {
1444
- className: cn("nav_bar_link", active && "nav_bar_link_active"),
1580
+ ref,
1581
+ className: cn("nav_bar_link", active && "nav_bar_link_active", className),
1445
1582
  "aria-current": active ? "page" : void 0,
1446
- ...props,
1583
+ ...rest,
1447
1584
  children
1448
1585
  }
1449
1586
  );
@@ -3702,11 +3839,13 @@ var AlertModal = ({
3702
3839
  useOverlayEscape(isOpen, () => dismiss());
3703
3840
  if (isOpen && !shouldRender) setShouldRender(true);
3704
3841
  const reduced = useReducedMotion();
3842
+ const dimOwner = React11.useRef({}).current;
3705
3843
  const overlayStyle = useSpring({
3706
3844
  ...springEnterFrom(reduced),
3707
3845
  to: { opacity: isOpen ? 1 : 0 },
3708
3846
  immediate: reduced,
3709
3847
  config: OVERLAY_SPRING_CONFIG,
3848
+ onChange: (result) => reportOverlayDim(dimOwner, Number(result.value.opacity)),
3710
3849
  onRest: (result) => {
3711
3850
  if (!isOpen && result.finished) setShouldRender(false);
3712
3851
  }
@@ -3722,8 +3861,12 @@ var AlertModal = ({
3722
3861
  });
3723
3862
  React11.useEffect(() => {
3724
3863
  if (!shouldRender) return;
3864
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
3725
3865
  lockBodyScroll();
3726
- return unlockBodyScroll;
3866
+ return () => {
3867
+ unlockBodyScroll();
3868
+ unregisterOverlayDim(dimOwner);
3869
+ };
3727
3870
  }, [shouldRender]);
3728
3871
  if (!isOpen && !shouldRender) return null;
3729
3872
  const confirmVariant = "filled";
@@ -4133,42 +4276,57 @@ var Combobox = ({
4133
4276
  }
4134
4277
  )
4135
4278
  ] }),
4136
- isOpen && /* @__PURE__ */ jsx(
4137
- animated.div,
4138
- {
4139
- className: cn("combobox_panel", { combobox_panel_up: popup.dropUp }),
4140
- style: panelStyle,
4141
- children: !hasList ? /* @__PURE__ */ jsx("p", { className: "combobox_message", role: "status", children: showIdle ? idleMessage : emptyMessage }) : /* @__PURE__ */ jsx(
4142
- "div",
4143
- {
4144
- ref: popup.listRef,
4145
- id: listId,
4146
- className: "combobox_list",
4147
- role: "listbox",
4148
- children: options.map((option, index) => (
4149
- /* biome-ignore lint/a11y/useKeyWithClickEvents: 키보드는 입력의 onKeyDown 이 담당한다 - option 은 aria-activedescendant 로 가리키는 비포커스 요소다 (APG Combobox) */
4150
- /* @__PURE__ */ jsx(
4151
- "div",
4152
- {
4153
- id: `${listId}-${option.value}`,
4154
- role: "option",
4155
- tabIndex: -1,
4156
- "aria-selected": value?.value === option.value,
4157
- "aria-disabled": option.disabled || void 0,
4158
- className: cn("combobox_option", {
4159
- is_active: index === activeIndex,
4160
- is_disabled: option.disabled
4161
- }),
4162
- onMouseEnter: () => !option.disabled && setActiveIndex(index),
4163
- onClick: () => !option.disabled && commit(option),
4164
- children: renderOption ? renderOption(option) : option.label
4165
- },
4166
- option.value
4167
- )
4168
- ))
4169
- }
4170
- )
4171
- }
4279
+ isOpen && typeof document !== "undefined" && createPortal(
4280
+ // Dropdown 과 같은 이유로 포탈이다 - 트리거 옆에 두면 `overflow: hidden` 조상이
4281
+ // 잘라낸다(#586). 좌표·폭은 배치 훅이 트리거를 재서 준다.
4282
+ /* @__PURE__ */ jsx(
4283
+ animated.div,
4284
+ {
4285
+ ref: popup.panelRef,
4286
+ className: cn("combobox_panel", { combobox_panel_up: popup.dropUp }),
4287
+ style: {
4288
+ ...panelStyle,
4289
+ position: "fixed",
4290
+ left: popup.position.x,
4291
+ top: popup.position.y,
4292
+ width: popup.position.width || void 0,
4293
+ // 트리거가 뷰포트보다 넓으면 좌표만 줄어들고 패널은 그대로 넘친다.
4294
+ maxWidth: popup.position.ready ? popup.position.maxWidth : void 0,
4295
+ visibility: popup.position.ready ? void 0 : "hidden"
4296
+ },
4297
+ children: !hasList ? /* @__PURE__ */ jsx("p", { className: "combobox_message", role: "status", children: showIdle ? idleMessage : emptyMessage }) : /* @__PURE__ */ jsx(
4298
+ "div",
4299
+ {
4300
+ ref: popup.listRef,
4301
+ id: listId,
4302
+ className: "combobox_list",
4303
+ role: "listbox",
4304
+ children: options.map((option, index) => (
4305
+ /* biome-ignore lint/a11y/useKeyWithClickEvents: 키보드는 입력의 onKeyDown 이 담당한다 - option aria-activedescendant 로 가리키는 비포커스 요소다 (APG Combobox) */
4306
+ /* @__PURE__ */ jsx(
4307
+ "div",
4308
+ {
4309
+ id: `${listId}-${option.value}`,
4310
+ role: "option",
4311
+ tabIndex: -1,
4312
+ "aria-selected": value?.value === option.value,
4313
+ "aria-disabled": option.disabled || void 0,
4314
+ className: cn("combobox_option", {
4315
+ is_active: index === activeIndex,
4316
+ is_disabled: option.disabled
4317
+ }),
4318
+ onMouseEnter: () => !option.disabled && setActiveIndex(index),
4319
+ onClick: () => !option.disabled && commit(option),
4320
+ children: renderOption ? renderOption(option) : option.label
4321
+ },
4322
+ option.value
4323
+ )
4324
+ ))
4325
+ }
4326
+ )
4327
+ }
4328
+ ),
4329
+ document.body
4172
4330
  )
4173
4331
  ] });
4174
4332
  };
@@ -4268,6 +4426,8 @@ var Dropdown = (props) => {
4268
4426
  wrapperRef,
4269
4427
  triggerRef: controlRef,
4270
4428
  listRef,
4429
+ panelRef,
4430
+ position,
4271
4431
  close: closePanel,
4272
4432
  onTriggerKeyDown: onControlKeyDown,
4273
4433
  onInputKeyDown: onSearchKeyDown
@@ -4335,86 +4495,110 @@ var Dropdown = (props) => {
4335
4495
  }
4336
4496
  ) }),
4337
4497
  name && (multiple ? selectedValues.map((v) => /* @__PURE__ */ jsx("input", { type: "hidden", name, value: v, disabled }, v)) : /* @__PURE__ */ jsx("input", { type: "hidden", name, value: selectedValues[0] ?? "", disabled })),
4338
- isOpen && /* @__PURE__ */ jsxs(animated.div, { className: listClassName, style: listStyle, children: [
4339
- searchable && /* @__PURE__ */ jsxs("div", { className: "dropdown_search", children: [
4340
- /* @__PURE__ */ jsx("span", { className: "dropdown_search_icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(Search, { size: iconSize.sm }) }),
4341
- /* @__PURE__ */ jsx(
4342
- "input",
4343
- {
4344
- ref: searchRef,
4345
- type: "text",
4346
- className: "dropdown_search_input",
4347
- placeholder: searchPlaceholder,
4348
- "aria-label": searchPlaceholder,
4349
- autoComplete: "off",
4350
- role: "combobox",
4351
- "aria-autocomplete": "list",
4352
- "aria-expanded": isOpen,
4353
- "aria-controls": `${dropdownId}_listbox`,
4354
- "aria-activedescendant": activeIndex >= 0 && visibleOptions[activeIndex] ? `${dropdownId}_option_${activeIndex}` : void 0,
4355
- value: searchText,
4356
- onChange: (e) => {
4357
- const v = e.target.value;
4358
- setSearchText(v);
4359
- if (!isComposingRef.current) setCommittedQuery(v);
4360
- },
4361
- onCompositionStart: () => {
4362
- isComposingRef.current = true;
4363
- },
4364
- onCompositionEnd: (e) => {
4365
- isComposingRef.current = false;
4366
- const v = e.currentTarget.value;
4367
- setSearchText(v);
4368
- setCommittedQuery(v);
4369
- },
4370
- onKeyDown: onSearchKeyDown
4371
- }
4372
- )
4373
- ] }),
4374
- /* @__PURE__ */ jsx(
4375
- "div",
4498
+ isOpen && typeof document !== "undefined" && createPortal(
4499
+ // 포탈로 body 띄운다. 트리거 옆에 두면 `overflow: hidden` 조상(카드·표
4500
+ // 래퍼)이 목록을 잘라내고 `z-index` 로는 넘지 못한다(#586 - 170px 46px
4501
+ // 보였다). 좌표·폭은 훅이 트리거를 재서 준다.
4502
+ /* @__PURE__ */ jsxs(
4503
+ animated.div,
4376
4504
  {
4377
- ref: listRef,
4378
- id: `${dropdownId}_listbox`,
4379
- role: "listbox",
4380
- className: "dropdown_options",
4381
- "aria-multiselectable": multiple || void 0,
4382
- children: visibleOptions.length === 0 ? searchable && /* @__PURE__ */ jsx("div", { className: "dropdown_empty", children: emptyText }) : visibleOptions.map((opt, i) => {
4383
- const selected = selectedValues.includes(opt.value);
4384
- const active = i === activeIndex;
4385
- return /* @__PURE__ */ jsxs(Fragment, { children: [
4386
- /* @__PURE__ */ jsxs(
4387
- "div",
4505
+ ref: panelRef,
4506
+ className: listClassName,
4507
+ style: {
4508
+ ...listStyle,
4509
+ position: "fixed",
4510
+ left: position.x,
4511
+ top: position.y,
4512
+ width: position.width || void 0,
4513
+ // 트리거가 뷰포트보다 넓으면 좌표만 줄어들고 패널은 그대로 넘친다.
4514
+ maxWidth: position.ready ? position.maxWidth : void 0,
4515
+ // 최초 측정 전에는 숨긴다 - (0,0) 에서 한 프레임 깜빡이는 것을 막는다.
4516
+ visibility: position.ready ? void 0 : "hidden"
4517
+ },
4518
+ children: [
4519
+ searchable && /* @__PURE__ */ jsxs("div", { className: "dropdown_search", children: [
4520
+ /* @__PURE__ */ jsx("span", { className: "dropdown_search_icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(Search, { size: iconSize.sm }) }),
4521
+ /* @__PURE__ */ jsx(
4522
+ "input",
4388
4523
  {
4389
- id: `${dropdownId}_option_${i}`,
4390
- role: "option",
4391
- tabIndex: -1,
4392
- "aria-selected": selected,
4393
- "aria-disabled": opt.disabled ? true : void 0,
4394
- className: cn("dropdown_option", {
4395
- is_selected: selected,
4396
- is_active: active,
4397
- is_disabled: opt.disabled
4398
- }),
4399
- onMouseEnter: () => !opt.disabled && setActiveIndex(i),
4400
- onClick: () => selectOption(opt),
4401
- children: [
4402
- multiple && /* @__PURE__ */ jsx("span", { className: "dropdown_option_check", "aria-hidden": "true", children: selected && /* @__PURE__ */ jsx(Check, { size: iconSize.sm }) }),
4403
- opt.leadingIcon && /* @__PURE__ */ jsx("span", { className: "dropdown_option_icon", children: opt.leadingIcon }),
4404
- /* @__PURE__ */ jsxs("span", { className: "dropdown_option_content", children: [
4405
- /* @__PURE__ */ jsx("span", { className: "dropdown_option_label", children: opt.label }),
4406
- opt.supportingText && /* @__PURE__ */ jsx("span", { className: "dropdown_option_supporting", children: opt.supportingText })
4407
- ] }),
4408
- (opt.trailingIcon || !multiple && selected) && /* @__PURE__ */ jsx("span", { className: "dropdown_option_trailing", children: opt.trailingIcon ?? /* @__PURE__ */ jsx(Check, { size: iconSize.sm, "aria-hidden": "true" }) })
4409
- ]
4524
+ ref: searchRef,
4525
+ type: "text",
4526
+ className: "dropdown_search_input",
4527
+ placeholder: searchPlaceholder,
4528
+ "aria-label": searchPlaceholder,
4529
+ autoComplete: "off",
4530
+ role: "combobox",
4531
+ "aria-autocomplete": "list",
4532
+ "aria-expanded": isOpen,
4533
+ "aria-controls": `${dropdownId}_listbox`,
4534
+ "aria-activedescendant": activeIndex >= 0 && visibleOptions[activeIndex] ? `${dropdownId}_option_${activeIndex}` : void 0,
4535
+ value: searchText,
4536
+ onChange: (e) => {
4537
+ const v = e.target.value;
4538
+ setSearchText(v);
4539
+ if (!isComposingRef.current) setCommittedQuery(v);
4540
+ },
4541
+ onCompositionStart: () => {
4542
+ isComposingRef.current = true;
4543
+ },
4544
+ onCompositionEnd: (e) => {
4545
+ isComposingRef.current = false;
4546
+ const v = e.currentTarget.value;
4547
+ setSearchText(v);
4548
+ setCommittedQuery(v);
4549
+ },
4550
+ onKeyDown: onSearchKeyDown
4410
4551
  }
4411
- ),
4412
- opt.showDivider && /* @__PURE__ */ jsx("hr", { className: "dropdown_option_divider" })
4413
- ] }, opt.value);
4414
- })
4552
+ )
4553
+ ] }),
4554
+ /* @__PURE__ */ jsx(
4555
+ "div",
4556
+ {
4557
+ ref: listRef,
4558
+ id: `${dropdownId}_listbox`,
4559
+ role: "listbox",
4560
+ className: "dropdown_options",
4561
+ "aria-multiselectable": multiple || void 0,
4562
+ children: visibleOptions.length === 0 ? searchable && /* @__PURE__ */ jsx("div", { className: "dropdown_empty", children: emptyText }) : visibleOptions.map((opt, i) => {
4563
+ const selected = selectedValues.includes(opt.value);
4564
+ const active = i === activeIndex;
4565
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
4566
+ /* @__PURE__ */ jsxs(
4567
+ "div",
4568
+ {
4569
+ id: `${dropdownId}_option_${i}`,
4570
+ role: "option",
4571
+ tabIndex: -1,
4572
+ "aria-selected": selected,
4573
+ "aria-disabled": opt.disabled ? true : void 0,
4574
+ className: cn("dropdown_option", {
4575
+ is_selected: selected,
4576
+ is_active: active,
4577
+ is_disabled: opt.disabled
4578
+ }),
4579
+ onMouseEnter: () => !opt.disabled && setActiveIndex(i),
4580
+ onClick: () => selectOption(opt),
4581
+ children: [
4582
+ multiple && /* @__PURE__ */ jsx("span", { className: "dropdown_option_check", "aria-hidden": "true", children: selected && /* @__PURE__ */ jsx(Check, { size: iconSize.sm }) }),
4583
+ opt.leadingIcon && /* @__PURE__ */ jsx("span", { className: "dropdown_option_icon", children: opt.leadingIcon }),
4584
+ /* @__PURE__ */ jsxs("span", { className: "dropdown_option_content", children: [
4585
+ /* @__PURE__ */ jsx("span", { className: "dropdown_option_label", children: opt.label }),
4586
+ opt.supportingText && /* @__PURE__ */ jsx("span", { className: "dropdown_option_supporting", children: opt.supportingText })
4587
+ ] }),
4588
+ (opt.trailingIcon || !multiple && selected) && /* @__PURE__ */ jsx("span", { className: "dropdown_option_trailing", children: opt.trailingIcon ?? /* @__PURE__ */ jsx(Check, { size: iconSize.sm, "aria-hidden": "true" }) })
4589
+ ]
4590
+ }
4591
+ ),
4592
+ opt.showDivider && /* @__PURE__ */ jsx("hr", { className: "dropdown_option_divider" })
4593
+ ] }, opt.value);
4594
+ })
4595
+ }
4596
+ )
4597
+ ]
4415
4598
  }
4416
- )
4417
- ] })
4599
+ ),
4600
+ document.body
4601
+ )
4418
4602
  ] });
4419
4603
  };
4420
4604
  var pad = (n) => String(n).padStart(2, "0");
@@ -5914,9 +6098,11 @@ var Drawer = ({
5914
6098
  if (escapeDismissible) onClose?.();
5915
6099
  });
5916
6100
  if (open && !shouldRender) setShouldRender(true);
6101
+ const dimOwner = React11.useRef({}).current;
5917
6102
  const overlayStyle = useSpringPresence({
5918
6103
  visible: open,
5919
6104
  from: "translateY(0px)",
6105
+ onProgress: (progress) => reportOverlayDim(dimOwner, progress),
5920
6106
  onExitComplete: () => {
5921
6107
  setShouldRender(false);
5922
6108
  onExited?.();
@@ -5932,8 +6118,12 @@ var Drawer = ({
5932
6118
  });
5933
6119
  React11.useEffect(() => {
5934
6120
  if (!shouldRender) return;
6121
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
5935
6122
  lockBodyScroll();
5936
- return unlockBodyScroll;
6123
+ return () => {
6124
+ unlockBodyScroll();
6125
+ unregisterOverlayDim(dimOwner);
6126
+ };
5937
6127
  }, [shouldRender]);
5938
6128
  if (!open && !shouldRender) return null;
5939
6129
  if (typeof document === "undefined" || !isMounted) return null;
@@ -6022,11 +6212,13 @@ var Modal = ({
6022
6212
  });
6023
6213
  if (open && !shouldRender) setShouldRender(true);
6024
6214
  const reduced = useReducedMotion();
6215
+ const dimOwner = React11.useRef({}).current;
6025
6216
  const overlayStyle = useSpring({
6026
6217
  ...springEnterFrom(reduced),
6027
6218
  to: { opacity: open ? 1 : 0 },
6028
6219
  immediate: reduced,
6029
6220
  config: OVERLAY_SPRING_CONFIG,
6221
+ onChange: (result) => reportOverlayDim(dimOwner, Number(result.value.opacity)),
6030
6222
  onRest: (result) => {
6031
6223
  if (open || !result.finished) return;
6032
6224
  setShouldRender(false);
@@ -6044,8 +6236,12 @@ var Modal = ({
6044
6236
  });
6045
6237
  React11.useEffect(() => {
6046
6238
  if (!shouldRender) return;
6239
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
6047
6240
  lockBodyScroll();
6048
- return unlockBodyScroll;
6241
+ return () => {
6242
+ unlockBodyScroll();
6243
+ unregisterOverlayDim(dimOwner);
6244
+ };
6049
6245
  }, [shouldRender]);
6050
6246
  if (!open && !shouldRender) return null;
6051
6247
  if (typeof document === "undefined" || !isMounted) return null;