@bigtablet/design-system 3.17.3 → 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
@@ -104,7 +104,7 @@ var over = (top, bottom) => [
104
104
  Math.round(top[2] * top[3] + bottom[2] * (1 - top[3])),
105
105
  1
106
106
  ];
107
- var compositeCanvasDim = (html, body) => {
107
+ var measureCanvasColors = (html, body) => {
108
108
  const raw = window.getComputedStyle(html).getPropertyValue(DIM_VAR).trim() || DIM_FALLBACK;
109
109
  const dim = normalizeColor(raw);
110
110
  if (!dim || dim[3] <= 0) return null;
@@ -113,8 +113,7 @@ var compositeCanvasDim = (html, body) => {
113
113
  parseRgb(window.getComputedStyle(body).backgroundColor)
114
114
  ];
115
115
  const base = candidates.find((color) => color !== null && color[3] > 0) ?? WHITE;
116
- const [r, g, b] = over(dim, over(base, WHITE));
117
- return `rgb(${r}, ${g}, ${b})`;
116
+ return [dim, over(base, WHITE)];
118
117
  };
119
118
  var measureViewportInset = () => {
120
119
  const probe = document.createElement("div");
@@ -125,6 +124,33 @@ var measureViewportInset = () => {
125
124
  if (width <= 0) return 0;
126
125
  return Math.max(0, window.innerWidth - width);
127
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
+ }
128
154
  function lockBodyScroll() {
129
155
  if (typeof document === "undefined") return;
130
156
  const body = document.body;
@@ -152,9 +178,10 @@ function lockBodyScroll() {
152
178
  }
153
179
  }
154
180
  if (scrollbarWidth > 0 && canReserveGutter) {
155
- const dimmed = compositeCanvasDim(html, body);
156
- if (dimmed) {
157
- html.style.backgroundColor = dimmed;
181
+ const measured = measureCanvasColors(html, body);
182
+ if (measured) {
183
+ [canvasDim, canvasBase] = measured;
184
+ paintCanvas(html);
158
185
  }
159
186
  }
160
187
  html.setAttribute(LOCKED_ATTR, "");
@@ -185,6 +212,9 @@ function unlockBodyScroll() {
185
212
  delete body.dataset[PREV_PADDING_RIGHT];
186
213
  delete body.dataset[PREV_SCROLLBAR_WIDTH_VAR];
187
214
  delete body.dataset[PREV_BACKGROUND_COLOR];
215
+ dimProgress.clear();
216
+ canvasBase = null;
217
+ canvasDim = null;
188
218
  } else {
189
219
  body.dataset[COUNTER] = String(remaining);
190
220
  }
@@ -250,15 +280,16 @@ function computeAnchoredPosition(anchor, floating, viewport, options) {
250
280
  if (!fitsMainAxis(side, anchor, sized, viewport, gap, padding) && fitsMainAxis(OPPOSITE[side], anchor, sized, viewport, gap, padding)) {
251
281
  side = OPPOSITE[side];
252
282
  }
283
+ const align = options.align ?? "center";
253
284
  let x;
254
285
  let y;
255
286
  if (isVertical(side)) {
256
287
  y = mainAxisStart(side, anchor, sized, gap);
257
- 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;
258
289
  x = clamp(x, padding, viewport.width - padding - sized.width);
259
290
  } else {
260
291
  x = mainAxisStart(side, anchor, sized, gap);
261
- 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;
262
293
  y = clamp(y, padding, viewport.height - padding - sized.height);
263
294
  }
264
295
  return { x, y, placement: side, maxWidth };
@@ -268,6 +299,7 @@ function useAnchoredPosition({
268
299
  anchorRef,
269
300
  floatingRef,
270
301
  placement,
302
+ align,
271
303
  gap,
272
304
  padding
273
305
  }) {
@@ -276,7 +308,8 @@ function useAnchoredPosition({
276
308
  y: 0,
277
309
  placement,
278
310
  maxWidth: 0,
279
- ready: false
311
+ ready: false,
312
+ anchorWidth: 0
280
313
  });
281
314
  useSafeLayoutEffect(() => {
282
315
  if (!open) {
@@ -293,9 +326,9 @@ function useAnchoredPosition({
293
326
  { top: a.top, left: a.left, width: a.width, height: a.height },
294
327
  { width: f.width, height: f.height },
295
328
  { width: window.innerWidth, height: window.innerHeight },
296
- { placement, gap, padding }
329
+ { placement, align, gap, padding }
297
330
  );
298
- setState({ ...result, ready: true });
331
+ setState({ ...result, ready: true, anchorWidth: a.width });
299
332
  };
300
333
  let frame = 0;
301
334
  const schedule = () => {
@@ -317,7 +350,7 @@ function useAnchoredPosition({
317
350
  window.removeEventListener("resize", schedule);
318
351
  observer?.disconnect();
319
352
  };
320
- }, [open, placement, gap, padding, anchorRef, floatingRef]);
353
+ }, [open, placement, align, gap, padding, anchorRef, floatingRef]);
321
354
  return state;
322
355
  }
323
356
  var SKIP_AUTOFOCUS_ATTR = "data-focus-trap-skip-autofocus";
@@ -426,7 +459,8 @@ function useSpringHover({
426
459
  const reduced = useReducedMotion();
427
460
  const style = useSpring({
428
461
  transform: hovered ? `translateY(${lift}px) scale(${scale})` : "translateY(0px) scale(1)",
429
- // reduced-motion: lift/scale 모션 제거 (WCAG 2.3.3)
462
+ // reduced-motion: 보간을 없애 움직임을 지운다. 최종 상태(lift/scale)는 남는다 - 강조가
463
+ // 사라지면 hover/focus 를 알 수 없다. WCAG 2.3.3 이 막는 것은 모션이다.
430
464
  immediate: reduced,
431
465
  config: { tension: 320, friction: 22 }
432
466
  });
@@ -441,7 +475,8 @@ function useSpringHover({
441
475
  function useSpringPresence({
442
476
  visible,
443
477
  from = "translateY(8px)",
444
- onExitComplete
478
+ onExitComplete,
479
+ onProgress
445
480
  }) {
446
481
  const reduced = useReducedMotion();
447
482
  return useSpring({
@@ -462,6 +497,7 @@ function useSpringPresence({
462
497
  clamp: !visible
463
498
  // 사라질 땐 진동 없이 빠르게
464
499
  },
500
+ onChange: (result) => onProgress?.(Number(result.value.opacity)),
465
501
  onRest: (result) => {
466
502
  if (!visible && result.finished && onExitComplete) {
467
503
  onExitComplete();
@@ -469,7 +505,8 @@ function useSpringPresence({
469
505
  }
470
506
  });
471
507
  }
472
- var MIN_SPACE_BELOW = 120;
508
+ var LIST_GAP = 4;
509
+ var LIST_PADDING = 8;
473
510
  function useListboxPopup({
474
511
  items,
475
512
  onCommit,
@@ -479,17 +516,19 @@ function useListboxPopup({
479
516
  }) {
480
517
  const [isOpen, setIsOpen] = useState(false);
481
518
  const [activeIndex, setActiveIndex] = useState(-1);
482
- const [dropUp, setDropUp] = useState(false);
483
519
  const wrapperRef = useRef(null);
484
520
  const triggerRef = useRef(null);
485
521
  const listRef = useRef(null);
522
+ const panelRef = useRef(null);
486
523
  const close = useCallback(() => {
487
524
  setIsOpen(false);
488
525
  if (returnFocusOnClose) triggerRef.current?.focus();
489
526
  }, [returnFocusOnClose]);
490
527
  useEffect(() => {
491
528
  const handleOutsideClick = (event) => {
492
- 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);
493
532
  };
494
533
  document.addEventListener("mousedown", handleOutsideClick);
495
534
  return () => document.removeEventListener("mousedown", handleOutsideClick);
@@ -608,22 +647,33 @@ function useListboxPopup({
608
647
  const option = list.querySelectorAll('[role="option"]')[activeIndex];
609
648
  option?.scrollIntoView?.({ block: "nearest" });
610
649
  }, [isOpen, activeIndex, items]);
611
- useEffect(() => {
612
- if (!isOpen || !triggerRef.current) return;
613
- const rect = triggerRef.current.getBoundingClientRect();
614
- const spaceBelow = window.innerHeight - rect.bottom;
615
- const spaceAbove = rect.top;
616
- setDropUp(spaceBelow < MIN_SPACE_BELOW && spaceAbove > spaceBelow);
617
- }, [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
+ });
618
660
  return {
619
661
  isOpen,
620
662
  setIsOpen,
621
- 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
+ },
622
671
  activeIndex,
623
672
  setActiveIndex,
624
673
  wrapperRef,
625
674
  triggerRef,
626
675
  listRef,
676
+ panelRef,
627
677
  close,
628
678
  moveActive,
629
679
  commitActive,
@@ -1171,25 +1221,46 @@ var Breadcrumb = ({
1171
1221
  const sep = separator ?? /* @__PURE__ */ jsx(ChevronRight, { size: iconSize.xs, "aria-hidden": "true" });
1172
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) => {
1173
1223
  const isLast = idx === items.length - 1;
1224
+ const Tag = item.as ?? "a";
1174
1225
  return (
1175
1226
  // biome-ignore lint/suspicious/noArrayIndexKey: breadcrumb items have stable order
1176
1227
  /* @__PURE__ */ jsxs("li", { className: "breadcrumb_item", children: [
1177
- 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 }),
1178
1233
  !isLast && /* @__PURE__ */ jsx("span", { className: "breadcrumb_separator", "aria-hidden": "true", children: sep })
1179
1234
  ] }, idx)
1180
1235
  );
1181
1236
  }) }) });
1182
1237
  };
1238
+ var MENU_GAP = 4;
1183
1239
  var Menu = ({ items, trigger, align = "start" }) => {
1184
1240
  const [open, setOpen] = React11.useState(false);
1185
1241
  const wrapperRef = React11.useRef(null);
1242
+ const menuRef = React11.useRef(null);
1186
1243
  const itemRefs = React11.useRef([]);
1187
1244
  const menuId = React11.useId();
1188
- 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
+ });
1189
1258
  React11.useEffect(() => {
1190
1259
  if (!open) return;
1191
1260
  const handleClick = (e) => {
1192
- 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);
1193
1264
  };
1194
1265
  const handleEsc = (e) => {
1195
1266
  if (e.key === "Escape") setOpen(false);
@@ -1212,8 +1283,8 @@ var Menu = ({ items, trigger, align = "start" }) => {
1212
1283
  if (enabled.length === 0) return;
1213
1284
  if (dir === "first") return void itemRefs.current[enabled[0]]?.focus();
1214
1285
  if (dir === "last") return void itemRefs.current[enabled[enabled.length - 1]]?.focus();
1215
- const pos = enabled.findIndex((i) => itemRefs.current[i] === document.activeElement);
1216
- 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;
1217
1288
  itemRefs.current[enabled[next]]?.focus();
1218
1289
  };
1219
1290
  const handleMenuKeyDown = (e) => {
@@ -1243,6 +1314,7 @@ var Menu = ({ items, trigger, align = "start" }) => {
1243
1314
  wrapperRef.current?.querySelector("[aria-haspopup]")?.focus();
1244
1315
  break;
1245
1316
  case "Tab":
1317
+ wrapperRef.current?.querySelector("[aria-haspopup]")?.focus();
1246
1318
  setOpen(false);
1247
1319
  break;
1248
1320
  }
@@ -1258,42 +1330,52 @@ var Menu = ({ items, trigger, align = "start" }) => {
1258
1330
  );
1259
1331
  return /* @__PURE__ */ jsxs("span", { className: "menu_wrapper", ref: wrapperRef, children: [
1260
1332
  triggerWithProps,
1261
- open && /* @__PURE__ */ jsx(
1262
- animated.div,
1263
- {
1264
- id: menuId,
1265
- role: "menu",
1266
- style,
1267
- className: cn("menu", `menu_align_${align}`),
1268
- onKeyDown: handleMenuKeyDown,
1269
- children: items.map((item, index) => /* @__PURE__ */ jsxs(
1270
- "button",
1271
- {
1272
- ref: (el) => {
1273
- itemRefs.current[index] = el;
1274
- },
1275
- type: "button",
1276
- role: "menuitem",
1277
- tabIndex: -1,
1278
- disabled: item.disabled,
1279
- className: cn(
1280
- "menu_item",
1281
- item.destructive && "menu_item_destructive",
1282
- item.disabled && "menu_item_disabled"
1283
- ),
1284
- onClick: () => {
1285
- if (item.disabled) return;
1286
- item.onSelect?.();
1287
- setOpen(false);
1288
- },
1289
- children: [
1290
- item.icon && /* @__PURE__ */ jsx("span", { className: "menu_item_icon", "aria-hidden": "true", children: item.icon }),
1291
- /* @__PURE__ */ jsx("span", { className: "menu_item_label", children: item.label })
1292
- ]
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"
1293
1346
  },
1294
- item.key
1295
- ))
1296
- }
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
1297
1379
  )
1298
1380
  ] });
1299
1381
  };
@@ -1489,13 +1571,16 @@ var LocaleSwitcher = ({ locale }) => {
1489
1571
  ) }, opt.value)) })
1490
1572
  ] });
1491
1573
  };
1492
- var NavLink = ({ active, className, children, ...props }) => {
1574
+ var NavLink = (props) => {
1575
+ const { active, className, children, as, ref, ...rest } = props;
1576
+ const Tag = as ?? "a";
1493
1577
  return /* @__PURE__ */ jsx(
1494
- "a",
1578
+ Tag,
1495
1579
  {
1496
- className: cn("nav_bar_link", active && "nav_bar_link_active"),
1580
+ ref,
1581
+ className: cn("nav_bar_link", active && "nav_bar_link_active", className),
1497
1582
  "aria-current": active ? "page" : void 0,
1498
- ...props,
1583
+ ...rest,
1499
1584
  children
1500
1585
  }
1501
1586
  );
@@ -3754,11 +3839,13 @@ var AlertModal = ({
3754
3839
  useOverlayEscape(isOpen, () => dismiss());
3755
3840
  if (isOpen && !shouldRender) setShouldRender(true);
3756
3841
  const reduced = useReducedMotion();
3842
+ const dimOwner = React11.useRef({}).current;
3757
3843
  const overlayStyle = useSpring({
3758
3844
  ...springEnterFrom(reduced),
3759
3845
  to: { opacity: isOpen ? 1 : 0 },
3760
3846
  immediate: reduced,
3761
3847
  config: OVERLAY_SPRING_CONFIG,
3848
+ onChange: (result) => reportOverlayDim(dimOwner, Number(result.value.opacity)),
3762
3849
  onRest: (result) => {
3763
3850
  if (!isOpen && result.finished) setShouldRender(false);
3764
3851
  }
@@ -3774,8 +3861,12 @@ var AlertModal = ({
3774
3861
  });
3775
3862
  React11.useEffect(() => {
3776
3863
  if (!shouldRender) return;
3864
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
3777
3865
  lockBodyScroll();
3778
- return unlockBodyScroll;
3866
+ return () => {
3867
+ unlockBodyScroll();
3868
+ unregisterOverlayDim(dimOwner);
3869
+ };
3779
3870
  }, [shouldRender]);
3780
3871
  if (!isOpen && !shouldRender) return null;
3781
3872
  const confirmVariant = "filled";
@@ -4185,42 +4276,57 @@ var Combobox = ({
4185
4276
  }
4186
4277
  )
4187
4278
  ] }),
4188
- isOpen && /* @__PURE__ */ jsx(
4189
- animated.div,
4190
- {
4191
- className: cn("combobox_panel", { combobox_panel_up: popup.dropUp }),
4192
- style: panelStyle,
4193
- children: !hasList ? /* @__PURE__ */ jsx("p", { className: "combobox_message", role: "status", children: showIdle ? idleMessage : emptyMessage }) : /* @__PURE__ */ jsx(
4194
- "div",
4195
- {
4196
- ref: popup.listRef,
4197
- id: listId,
4198
- className: "combobox_list",
4199
- role: "listbox",
4200
- children: options.map((option, index) => (
4201
- /* biome-ignore lint/a11y/useKeyWithClickEvents: 키보드는 입력의 onKeyDown 이 담당한다 - option 은 aria-activedescendant 로 가리키는 비포커스 요소다 (APG Combobox) */
4202
- /* @__PURE__ */ jsx(
4203
- "div",
4204
- {
4205
- id: `${listId}-${option.value}`,
4206
- role: "option",
4207
- tabIndex: -1,
4208
- "aria-selected": value?.value === option.value,
4209
- "aria-disabled": option.disabled || void 0,
4210
- className: cn("combobox_option", {
4211
- is_active: index === activeIndex,
4212
- is_disabled: option.disabled
4213
- }),
4214
- onMouseEnter: () => !option.disabled && setActiveIndex(index),
4215
- onClick: () => !option.disabled && commit(option),
4216
- children: renderOption ? renderOption(option) : option.label
4217
- },
4218
- option.value
4219
- )
4220
- ))
4221
- }
4222
- )
4223
- }
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
4224
4330
  )
4225
4331
  ] });
4226
4332
  };
@@ -4320,6 +4426,8 @@ var Dropdown = (props) => {
4320
4426
  wrapperRef,
4321
4427
  triggerRef: controlRef,
4322
4428
  listRef,
4429
+ panelRef,
4430
+ position,
4323
4431
  close: closePanel,
4324
4432
  onTriggerKeyDown: onControlKeyDown,
4325
4433
  onInputKeyDown: onSearchKeyDown
@@ -4387,86 +4495,110 @@ var Dropdown = (props) => {
4387
4495
  }
4388
4496
  ) }),
4389
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 })),
4390
- isOpen && /* @__PURE__ */ jsxs(animated.div, { className: listClassName, style: listStyle, children: [
4391
- searchable && /* @__PURE__ */ jsxs("div", { className: "dropdown_search", children: [
4392
- /* @__PURE__ */ jsx("span", { className: "dropdown_search_icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(Search, { size: iconSize.sm }) }),
4393
- /* @__PURE__ */ jsx(
4394
- "input",
4395
- {
4396
- ref: searchRef,
4397
- type: "text",
4398
- className: "dropdown_search_input",
4399
- placeholder: searchPlaceholder,
4400
- "aria-label": searchPlaceholder,
4401
- autoComplete: "off",
4402
- role: "combobox",
4403
- "aria-autocomplete": "list",
4404
- "aria-expanded": isOpen,
4405
- "aria-controls": `${dropdownId}_listbox`,
4406
- "aria-activedescendant": activeIndex >= 0 && visibleOptions[activeIndex] ? `${dropdownId}_option_${activeIndex}` : void 0,
4407
- value: searchText,
4408
- onChange: (e) => {
4409
- const v = e.target.value;
4410
- setSearchText(v);
4411
- if (!isComposingRef.current) setCommittedQuery(v);
4412
- },
4413
- onCompositionStart: () => {
4414
- isComposingRef.current = true;
4415
- },
4416
- onCompositionEnd: (e) => {
4417
- isComposingRef.current = false;
4418
- const v = e.currentTarget.value;
4419
- setSearchText(v);
4420
- setCommittedQuery(v);
4421
- },
4422
- onKeyDown: onSearchKeyDown
4423
- }
4424
- )
4425
- ] }),
4426
- /* @__PURE__ */ jsx(
4427
- "div",
4498
+ isOpen && typeof document !== "undefined" && createPortal(
4499
+ // 포탈로 body 띄운다. 트리거 옆에 두면 `overflow: hidden` 조상(카드·표
4500
+ // 래퍼)이 목록을 잘라내고 `z-index` 로는 넘지 못한다(#586 - 170px 46px
4501
+ // 보였다). 좌표·폭은 훅이 트리거를 재서 준다.
4502
+ /* @__PURE__ */ jsxs(
4503
+ animated.div,
4428
4504
  {
4429
- ref: listRef,
4430
- id: `${dropdownId}_listbox`,
4431
- role: "listbox",
4432
- className: "dropdown_options",
4433
- "aria-multiselectable": multiple || void 0,
4434
- children: visibleOptions.length === 0 ? searchable && /* @__PURE__ */ jsx("div", { className: "dropdown_empty", children: emptyText }) : visibleOptions.map((opt, i) => {
4435
- const selected = selectedValues.includes(opt.value);
4436
- const active = i === activeIndex;
4437
- return /* @__PURE__ */ jsxs(Fragment, { children: [
4438
- /* @__PURE__ */ jsxs(
4439
- "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",
4440
4523
  {
4441
- id: `${dropdownId}_option_${i}`,
4442
- role: "option",
4443
- tabIndex: -1,
4444
- "aria-selected": selected,
4445
- "aria-disabled": opt.disabled ? true : void 0,
4446
- className: cn("dropdown_option", {
4447
- is_selected: selected,
4448
- is_active: active,
4449
- is_disabled: opt.disabled
4450
- }),
4451
- onMouseEnter: () => !opt.disabled && setActiveIndex(i),
4452
- onClick: () => selectOption(opt),
4453
- children: [
4454
- multiple && /* @__PURE__ */ jsx("span", { className: "dropdown_option_check", "aria-hidden": "true", children: selected && /* @__PURE__ */ jsx(Check, { size: iconSize.sm }) }),
4455
- opt.leadingIcon && /* @__PURE__ */ jsx("span", { className: "dropdown_option_icon", children: opt.leadingIcon }),
4456
- /* @__PURE__ */ jsxs("span", { className: "dropdown_option_content", children: [
4457
- /* @__PURE__ */ jsx("span", { className: "dropdown_option_label", children: opt.label }),
4458
- opt.supportingText && /* @__PURE__ */ jsx("span", { className: "dropdown_option_supporting", children: opt.supportingText })
4459
- ] }),
4460
- (opt.trailingIcon || !multiple && selected) && /* @__PURE__ */ jsx("span", { className: "dropdown_option_trailing", children: opt.trailingIcon ?? /* @__PURE__ */ jsx(Check, { size: iconSize.sm, "aria-hidden": "true" }) })
4461
- ]
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
4462
4551
  }
4463
- ),
4464
- opt.showDivider && /* @__PURE__ */ jsx("hr", { className: "dropdown_option_divider" })
4465
- ] }, opt.value);
4466
- })
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
+ ]
4467
4598
  }
4468
- )
4469
- ] })
4599
+ ),
4600
+ document.body
4601
+ )
4470
4602
  ] });
4471
4603
  };
4472
4604
  var pad = (n) => String(n).padStart(2, "0");
@@ -5966,9 +6098,11 @@ var Drawer = ({
5966
6098
  if (escapeDismissible) onClose?.();
5967
6099
  });
5968
6100
  if (open && !shouldRender) setShouldRender(true);
6101
+ const dimOwner = React11.useRef({}).current;
5969
6102
  const overlayStyle = useSpringPresence({
5970
6103
  visible: open,
5971
6104
  from: "translateY(0px)",
6105
+ onProgress: (progress) => reportOverlayDim(dimOwner, progress),
5972
6106
  onExitComplete: () => {
5973
6107
  setShouldRender(false);
5974
6108
  onExited?.();
@@ -5984,8 +6118,12 @@ var Drawer = ({
5984
6118
  });
5985
6119
  React11.useEffect(() => {
5986
6120
  if (!shouldRender) return;
6121
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
5987
6122
  lockBodyScroll();
5988
- return unlockBodyScroll;
6123
+ return () => {
6124
+ unlockBodyScroll();
6125
+ unregisterOverlayDim(dimOwner);
6126
+ };
5989
6127
  }, [shouldRender]);
5990
6128
  if (!open && !shouldRender) return null;
5991
6129
  if (typeof document === "undefined" || !isMounted) return null;
@@ -6074,11 +6212,13 @@ var Modal = ({
6074
6212
  });
6075
6213
  if (open && !shouldRender) setShouldRender(true);
6076
6214
  const reduced = useReducedMotion();
6215
+ const dimOwner = React11.useRef({}).current;
6077
6216
  const overlayStyle = useSpring({
6078
6217
  ...springEnterFrom(reduced),
6079
6218
  to: { opacity: open ? 1 : 0 },
6080
6219
  immediate: reduced,
6081
6220
  config: OVERLAY_SPRING_CONFIG,
6221
+ onChange: (result) => reportOverlayDim(dimOwner, Number(result.value.opacity)),
6082
6222
  onRest: (result) => {
6083
6223
  if (open || !result.finished) return;
6084
6224
  setShouldRender(false);
@@ -6096,8 +6236,12 @@ var Modal = ({
6096
6236
  });
6097
6237
  React11.useEffect(() => {
6098
6238
  if (!shouldRender) return;
6239
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
6099
6240
  lockBodyScroll();
6100
- return unlockBodyScroll;
6241
+ return () => {
6242
+ unlockBodyScroll();
6243
+ unregisterOverlayDim(dimOwner);
6244
+ };
6101
6245
  }, [shouldRender]);
6102
6246
  if (!open && !shouldRender) return null;
6103
6247
  if (typeof document === "undefined" || !isMounted) return null;