@bigtablet/design-system 3.17.3 → 3.18.1

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,13 @@ 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({
332
+ ...result,
333
+ ready: true,
334
+ anchorWidth: Math.min(a.width, result.maxWidth)
335
+ });
299
336
  };
300
337
  let frame = 0;
301
338
  const schedule = () => {
@@ -317,7 +354,7 @@ function useAnchoredPosition({
317
354
  window.removeEventListener("resize", schedule);
318
355
  observer?.disconnect();
319
356
  };
320
- }, [open, placement, gap, padding, anchorRef, floatingRef]);
357
+ }, [open, placement, align, gap, padding, anchorRef, floatingRef]);
321
358
  return state;
322
359
  }
323
360
  var SKIP_AUTOFOCUS_ATTR = "data-focus-trap-skip-autofocus";
@@ -426,7 +463,8 @@ function useSpringHover({
426
463
  const reduced = useReducedMotion();
427
464
  const style = useSpring({
428
465
  transform: hovered ? `translateY(${lift}px) scale(${scale})` : "translateY(0px) scale(1)",
429
- // reduced-motion: lift/scale 모션 제거 (WCAG 2.3.3)
466
+ // reduced-motion: 보간을 없애 움직임을 지운다. 최종 상태(lift/scale)는 남는다 - 강조가
467
+ // 사라지면 hover/focus 를 알 수 없다. WCAG 2.3.3 이 막는 것은 모션이다.
430
468
  immediate: reduced,
431
469
  config: { tension: 320, friction: 22 }
432
470
  });
@@ -441,7 +479,8 @@ function useSpringHover({
441
479
  function useSpringPresence({
442
480
  visible,
443
481
  from = "translateY(8px)",
444
- onExitComplete
482
+ onExitComplete,
483
+ onProgress
445
484
  }) {
446
485
  const reduced = useReducedMotion();
447
486
  return useSpring({
@@ -462,6 +501,7 @@ function useSpringPresence({
462
501
  clamp: !visible
463
502
  // 사라질 땐 진동 없이 빠르게
464
503
  },
504
+ onChange: (result) => onProgress?.(Number(result.value.opacity)),
465
505
  onRest: (result) => {
466
506
  if (!visible && result.finished && onExitComplete) {
467
507
  onExitComplete();
@@ -469,7 +509,8 @@ function useSpringPresence({
469
509
  }
470
510
  });
471
511
  }
472
- var MIN_SPACE_BELOW = 120;
512
+ var LIST_GAP = 4;
513
+ var LIST_PADDING = 8;
473
514
  function useListboxPopup({
474
515
  items,
475
516
  onCommit,
@@ -479,17 +520,19 @@ function useListboxPopup({
479
520
  }) {
480
521
  const [isOpen, setIsOpen] = useState(false);
481
522
  const [activeIndex, setActiveIndex] = useState(-1);
482
- const [dropUp, setDropUp] = useState(false);
483
523
  const wrapperRef = useRef(null);
484
524
  const triggerRef = useRef(null);
485
525
  const listRef = useRef(null);
526
+ const panelRef = useRef(null);
486
527
  const close = useCallback(() => {
487
528
  setIsOpen(false);
488
529
  if (returnFocusOnClose) triggerRef.current?.focus();
489
530
  }, [returnFocusOnClose]);
490
531
  useEffect(() => {
491
532
  const handleOutsideClick = (event) => {
492
- if (!wrapperRef.current?.contains(event.target)) setIsOpen(false);
533
+ const target = event.target;
534
+ if (wrapperRef.current?.contains(target) || panelRef.current?.contains(target)) return;
535
+ setIsOpen(false);
493
536
  };
494
537
  document.addEventListener("mousedown", handleOutsideClick);
495
538
  return () => document.removeEventListener("mousedown", handleOutsideClick);
@@ -608,22 +651,33 @@ function useListboxPopup({
608
651
  const option = list.querySelectorAll('[role="option"]')[activeIndex];
609
652
  option?.scrollIntoView?.({ block: "nearest" });
610
653
  }, [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]);
654
+ const anchored = useAnchoredPosition({
655
+ open: isOpen,
656
+ anchorRef: wrapperRef,
657
+ floatingRef: panelRef,
658
+ placement: "bottom",
659
+ // 트리거 왼쪽 변에 맞춘다 - 목록이 컨트롤의 연장으로 읽혀야 한다.
660
+ align: "start",
661
+ gap: LIST_GAP,
662
+ padding: LIST_PADDING
663
+ });
618
664
  return {
619
665
  isOpen,
620
666
  setIsOpen,
621
- dropUp,
667
+ dropUp: anchored.placement === "top",
668
+ position: {
669
+ x: anchored.x,
670
+ y: anchored.y,
671
+ width: anchored.anchorWidth,
672
+ maxWidth: anchored.maxWidth,
673
+ ready: anchored.ready
674
+ },
622
675
  activeIndex,
623
676
  setActiveIndex,
624
677
  wrapperRef,
625
678
  triggerRef,
626
679
  listRef,
680
+ panelRef,
627
681
  close,
628
682
  moveActive,
629
683
  commitActive,
@@ -1171,25 +1225,46 @@ var Breadcrumb = ({
1171
1225
  const sep = separator ?? /* @__PURE__ */ jsx(ChevronRight, { size: iconSize.xs, "aria-hidden": "true" });
1172
1226
  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
1227
  const isLast = idx === items.length - 1;
1228
+ const Tag = item.as ?? "a";
1174
1229
  return (
1175
1230
  // biome-ignore lint/suspicious/noArrayIndexKey: breadcrumb items have stable order
1176
1231
  /* @__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 }),
1232
+ isLast ? /* @__PURE__ */ jsx("span", { className: "breadcrumb_current", "aria-current": "page", children: item.label }) : item.href ? (
1233
+ // `href`·`className`·`onClick` 을 그대로 넘긴다 - 라우터 `Link` 가 수정자
1234
+ // 클릭·프리페치를 자기 방식대로 처리한다.
1235
+ /* @__PURE__ */ jsx(Tag, { className: "breadcrumb_link", href: item.href, onClick: item.onClick, children: item.label })
1236
+ ) : /* @__PURE__ */ jsx("button", { type: "button", className: "breadcrumb_link", onClick: item.onClick, children: item.label }),
1178
1237
  !isLast && /* @__PURE__ */ jsx("span", { className: "breadcrumb_separator", "aria-hidden": "true", children: sep })
1179
1238
  ] }, idx)
1180
1239
  );
1181
1240
  }) }) });
1182
1241
  };
1242
+ var MENU_GAP = 4;
1183
1243
  var Menu = ({ items, trigger, align = "start" }) => {
1184
1244
  const [open, setOpen] = React11.useState(false);
1185
1245
  const wrapperRef = React11.useRef(null);
1246
+ const menuRef = React11.useRef(null);
1186
1247
  const itemRefs = React11.useRef([]);
1187
1248
  const menuId = React11.useId();
1188
- const style = useSpringPresence({ visible: open, from: "translateY(-4px)" });
1249
+ const pos = useAnchoredPosition({
1250
+ open,
1251
+ anchorRef: wrapperRef,
1252
+ floatingRef: menuRef,
1253
+ placement: "bottom",
1254
+ align,
1255
+ gap: MENU_GAP,
1256
+ padding: 8
1257
+ });
1258
+ const style = useSpringPresence({
1259
+ visible: open,
1260
+ from: pos.placement === "top" ? `translateY(${MENU_GAP}px)` : `translateY(-${MENU_GAP}px)`
1261
+ });
1189
1262
  React11.useEffect(() => {
1190
1263
  if (!open) return;
1191
1264
  const handleClick = (e) => {
1192
- if (!wrapperRef.current?.contains(e.target)) setOpen(false);
1265
+ const target = e.target;
1266
+ if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) return;
1267
+ setOpen(false);
1193
1268
  };
1194
1269
  const handleEsc = (e) => {
1195
1270
  if (e.key === "Escape") setOpen(false);
@@ -1212,8 +1287,8 @@ var Menu = ({ items, trigger, align = "start" }) => {
1212
1287
  if (enabled.length === 0) return;
1213
1288
  if (dir === "first") return void itemRefs.current[enabled[0]]?.focus();
1214
1289
  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;
1290
+ const pos2 = enabled.findIndex((i) => itemRefs.current[i] === document.activeElement);
1291
+ const next = pos2 < 0 ? dir === 1 ? 0 : enabled.length - 1 : (pos2 + dir + enabled.length) % enabled.length;
1217
1292
  itemRefs.current[enabled[next]]?.focus();
1218
1293
  };
1219
1294
  const handleMenuKeyDown = (e) => {
@@ -1243,6 +1318,7 @@ var Menu = ({ items, trigger, align = "start" }) => {
1243
1318
  wrapperRef.current?.querySelector("[aria-haspopup]")?.focus();
1244
1319
  break;
1245
1320
  case "Tab":
1321
+ wrapperRef.current?.querySelector("[aria-haspopup]")?.focus();
1246
1322
  setOpen(false);
1247
1323
  break;
1248
1324
  }
@@ -1258,42 +1334,52 @@ var Menu = ({ items, trigger, align = "start" }) => {
1258
1334
  );
1259
1335
  return /* @__PURE__ */ jsxs("span", { className: "menu_wrapper", ref: wrapperRef, children: [
1260
1336
  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
- ]
1337
+ open && typeof document !== "undefined" && createPortal(
1338
+ /* @__PURE__ */ jsx(
1339
+ animated.div,
1340
+ {
1341
+ id: menuId,
1342
+ ref: menuRef,
1343
+ role: "menu",
1344
+ style: {
1345
+ ...style,
1346
+ position: "fixed",
1347
+ left: pos.x,
1348
+ top: pos.y,
1349
+ visibility: pos.ready ? void 0 : "hidden"
1293
1350
  },
1294
- item.key
1295
- ))
1296
- }
1351
+ className: cn("menu", `menu_align_${align}`),
1352
+ onKeyDown: handleMenuKeyDown,
1353
+ children: items.map((item, index) => /* @__PURE__ */ jsxs(
1354
+ "button",
1355
+ {
1356
+ ref: (el) => {
1357
+ itemRefs.current[index] = el;
1358
+ },
1359
+ type: "button",
1360
+ role: "menuitem",
1361
+ tabIndex: -1,
1362
+ disabled: item.disabled,
1363
+ className: cn(
1364
+ "menu_item",
1365
+ item.destructive && "menu_item_destructive",
1366
+ item.disabled && "menu_item_disabled"
1367
+ ),
1368
+ onClick: () => {
1369
+ if (item.disabled) return;
1370
+ item.onSelect?.();
1371
+ setOpen(false);
1372
+ },
1373
+ children: [
1374
+ item.icon && /* @__PURE__ */ jsx("span", { className: "menu_item_icon", "aria-hidden": "true", children: item.icon }),
1375
+ /* @__PURE__ */ jsx("span", { className: "menu_item_label", children: item.label })
1376
+ ]
1377
+ },
1378
+ item.key
1379
+ ))
1380
+ }
1381
+ ),
1382
+ document.body
1297
1383
  )
1298
1384
  ] });
1299
1385
  };
@@ -1489,13 +1575,16 @@ var LocaleSwitcher = ({ locale }) => {
1489
1575
  ) }, opt.value)) })
1490
1576
  ] });
1491
1577
  };
1492
- var NavLink = ({ active, className, children, ...props }) => {
1578
+ var NavLink = (props) => {
1579
+ const { active, className, children, as, ref, ...rest } = props;
1580
+ const Tag = as ?? "a";
1493
1581
  return /* @__PURE__ */ jsx(
1494
- "a",
1582
+ Tag,
1495
1583
  {
1496
- className: cn("nav_bar_link", active && "nav_bar_link_active"),
1584
+ ref,
1585
+ className: cn("nav_bar_link", active && "nav_bar_link_active", className),
1497
1586
  "aria-current": active ? "page" : void 0,
1498
- ...props,
1587
+ ...rest,
1499
1588
  children
1500
1589
  }
1501
1590
  );
@@ -3754,11 +3843,13 @@ var AlertModal = ({
3754
3843
  useOverlayEscape(isOpen, () => dismiss());
3755
3844
  if (isOpen && !shouldRender) setShouldRender(true);
3756
3845
  const reduced = useReducedMotion();
3846
+ const dimOwner = React11.useRef({}).current;
3757
3847
  const overlayStyle = useSpring({
3758
3848
  ...springEnterFrom(reduced),
3759
3849
  to: { opacity: isOpen ? 1 : 0 },
3760
3850
  immediate: reduced,
3761
3851
  config: OVERLAY_SPRING_CONFIG,
3852
+ onChange: (result) => reportOverlayDim(dimOwner, Number(result.value.opacity)),
3762
3853
  onRest: (result) => {
3763
3854
  if (!isOpen && result.finished) setShouldRender(false);
3764
3855
  }
@@ -3774,8 +3865,12 @@ var AlertModal = ({
3774
3865
  });
3775
3866
  React11.useEffect(() => {
3776
3867
  if (!shouldRender) return;
3868
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
3777
3869
  lockBodyScroll();
3778
- return unlockBodyScroll;
3870
+ return () => {
3871
+ unlockBodyScroll();
3872
+ unregisterOverlayDim(dimOwner);
3873
+ };
3779
3874
  }, [shouldRender]);
3780
3875
  if (!isOpen && !shouldRender) return null;
3781
3876
  const confirmVariant = "filled";
@@ -4185,42 +4280,59 @@ var Combobox = ({
4185
4280
  }
4186
4281
  )
4187
4282
  ] }),
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
- }
4283
+ isOpen && typeof document !== "undefined" && createPortal(
4284
+ // Dropdown 과 같은 이유로 포탈이다 - 트리거 옆에 두면 `overflow: hidden` 조상이
4285
+ // 잘라낸다(#586). 좌표·폭은 배치 훅이 트리거를 재서 준다.
4286
+ /* @__PURE__ */ jsx(
4287
+ animated.div,
4288
+ {
4289
+ ref: popup.panelRef,
4290
+ className: cn("combobox_panel", { combobox_panel_up: popup.dropUp }),
4291
+ style: {
4292
+ ...panelStyle,
4293
+ position: "fixed",
4294
+ left: popup.position.x,
4295
+ top: popup.position.y,
4296
+ // 트리거 폭은 하한 - 못박으면 좁은 트리거에서 옵션 라벨이 잘린다(#596).
4297
+ // 실제 폭은 `width: max-content`(style.scss)가 내용 기준으로 정한다.
4298
+ minWidth: popup.position.width || void 0,
4299
+ // 트리거가 뷰포트보다 넓으면 좌표만 줄어들고 패널은 그대로 넘친다.
4300
+ maxWidth: popup.position.ready ? popup.position.maxWidth : void 0,
4301
+ visibility: popup.position.ready ? void 0 : "hidden"
4302
+ },
4303
+ children: !hasList ? /* @__PURE__ */ jsx("p", { className: "combobox_message", role: "status", children: showIdle ? idleMessage : emptyMessage }) : /* @__PURE__ */ jsx(
4304
+ "div",
4305
+ {
4306
+ ref: popup.listRef,
4307
+ id: listId,
4308
+ className: "combobox_list",
4309
+ role: "listbox",
4310
+ children: options.map((option, index) => (
4311
+ /* biome-ignore lint/a11y/useKeyWithClickEvents: 키보드는 입력의 onKeyDown 이 담당한다 - option 은 aria-activedescendant 로 가리키는 비포커스 요소다 (APG Combobox) */
4312
+ /* @__PURE__ */ jsx(
4313
+ "div",
4314
+ {
4315
+ id: `${listId}-${option.value}`,
4316
+ role: "option",
4317
+ tabIndex: -1,
4318
+ "aria-selected": value?.value === option.value,
4319
+ "aria-disabled": option.disabled || void 0,
4320
+ className: cn("combobox_option", {
4321
+ is_active: index === activeIndex,
4322
+ is_disabled: option.disabled
4323
+ }),
4324
+ onMouseEnter: () => !option.disabled && setActiveIndex(index),
4325
+ onClick: () => !option.disabled && commit(option),
4326
+ children: renderOption ? renderOption(option) : option.label
4327
+ },
4328
+ option.value
4329
+ )
4330
+ ))
4331
+ }
4332
+ )
4333
+ }
4334
+ ),
4335
+ document.body
4224
4336
  )
4225
4337
  ] });
4226
4338
  };
@@ -4320,6 +4432,8 @@ var Dropdown = (props) => {
4320
4432
  wrapperRef,
4321
4433
  triggerRef: controlRef,
4322
4434
  listRef,
4435
+ panelRef,
4436
+ position,
4323
4437
  close: closePanel,
4324
4438
  onTriggerKeyDown: onControlKeyDown,
4325
4439
  onInputKeyDown: onSearchKeyDown
@@ -4387,86 +4501,113 @@ var Dropdown = (props) => {
4387
4501
  }
4388
4502
  ) }),
4389
4503
  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",
4504
+ isOpen && typeof document !== "undefined" && createPortal(
4505
+ // 포탈로 body 띄운다. 트리거 옆에 두면 `overflow: hidden` 조상(카드·표
4506
+ // 래퍼)이 목록을 잘라내고 `z-index` 로는 넘지 못한다(#586 - 170px 46px
4507
+ // 보였다). 좌표·폭은 훅이 트리거를 재서 준다.
4508
+ /* @__PURE__ */ jsxs(
4509
+ animated.div,
4428
4510
  {
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",
4511
+ ref: panelRef,
4512
+ className: listClassName,
4513
+ style: {
4514
+ ...listStyle,
4515
+ position: "fixed",
4516
+ left: position.x,
4517
+ top: position.y,
4518
+ // 트리거 폭은 **하한**이다 - 못박으면 트리거가 좁을 때 목록이 자기 옵션
4519
+ // 라벨을 ellipsis 접는다(#596 - 48px 트리거에서 `02` 가 `0.`). 실제 폭은
4520
+ // `width: max-content`(style.scss)가 내용 기준으로 정하고 maxWidth 가 막는다.
4521
+ minWidth: position.width || void 0,
4522
+ // 트리거가 뷰포트보다 넓으면 좌표만 줄어들고 패널은 그대로 넘친다.
4523
+ maxWidth: position.ready ? position.maxWidth : void 0,
4524
+ // 최초 측정 전에는 숨긴다 - (0,0) 에서 한 프레임 깜빡이는 것을 막는다.
4525
+ visibility: position.ready ? void 0 : "hidden"
4526
+ },
4527
+ children: [
4528
+ searchable && /* @__PURE__ */ jsxs("div", { className: "dropdown_search", children: [
4529
+ /* @__PURE__ */ jsx("span", { className: "dropdown_search_icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(Search, { size: iconSize.sm }) }),
4530
+ /* @__PURE__ */ jsx(
4531
+ "input",
4440
4532
  {
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
- ]
4533
+ ref: searchRef,
4534
+ type: "text",
4535
+ className: "dropdown_search_input",
4536
+ placeholder: searchPlaceholder,
4537
+ "aria-label": searchPlaceholder,
4538
+ autoComplete: "off",
4539
+ role: "combobox",
4540
+ "aria-autocomplete": "list",
4541
+ "aria-expanded": isOpen,
4542
+ "aria-controls": `${dropdownId}_listbox`,
4543
+ "aria-activedescendant": activeIndex >= 0 && visibleOptions[activeIndex] ? `${dropdownId}_option_${activeIndex}` : void 0,
4544
+ value: searchText,
4545
+ onChange: (e) => {
4546
+ const v = e.target.value;
4547
+ setSearchText(v);
4548
+ if (!isComposingRef.current) setCommittedQuery(v);
4549
+ },
4550
+ onCompositionStart: () => {
4551
+ isComposingRef.current = true;
4552
+ },
4553
+ onCompositionEnd: (e) => {
4554
+ isComposingRef.current = false;
4555
+ const v = e.currentTarget.value;
4556
+ setSearchText(v);
4557
+ setCommittedQuery(v);
4558
+ },
4559
+ onKeyDown: onSearchKeyDown
4462
4560
  }
4463
- ),
4464
- opt.showDivider && /* @__PURE__ */ jsx("hr", { className: "dropdown_option_divider" })
4465
- ] }, opt.value);
4466
- })
4561
+ )
4562
+ ] }),
4563
+ /* @__PURE__ */ jsx(
4564
+ "div",
4565
+ {
4566
+ ref: listRef,
4567
+ id: `${dropdownId}_listbox`,
4568
+ role: "listbox",
4569
+ className: "dropdown_options",
4570
+ "aria-multiselectable": multiple || void 0,
4571
+ children: visibleOptions.length === 0 ? searchable && /* @__PURE__ */ jsx("div", { className: "dropdown_empty", children: emptyText }) : visibleOptions.map((opt, i) => {
4572
+ const selected = selectedValues.includes(opt.value);
4573
+ const active = i === activeIndex;
4574
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
4575
+ /* @__PURE__ */ jsxs(
4576
+ "div",
4577
+ {
4578
+ id: `${dropdownId}_option_${i}`,
4579
+ role: "option",
4580
+ tabIndex: -1,
4581
+ "aria-selected": selected,
4582
+ "aria-disabled": opt.disabled ? true : void 0,
4583
+ className: cn("dropdown_option", {
4584
+ is_selected: selected,
4585
+ is_active: active,
4586
+ is_disabled: opt.disabled
4587
+ }),
4588
+ onMouseEnter: () => !opt.disabled && setActiveIndex(i),
4589
+ onClick: () => selectOption(opt),
4590
+ children: [
4591
+ multiple && /* @__PURE__ */ jsx("span", { className: "dropdown_option_check", "aria-hidden": "true", children: selected && /* @__PURE__ */ jsx(Check, { size: iconSize.sm }) }),
4592
+ opt.leadingIcon && /* @__PURE__ */ jsx("span", { className: "dropdown_option_icon", children: opt.leadingIcon }),
4593
+ /* @__PURE__ */ jsxs("span", { className: "dropdown_option_content", children: [
4594
+ /* @__PURE__ */ jsx("span", { className: "dropdown_option_label", children: opt.label }),
4595
+ opt.supportingText && /* @__PURE__ */ jsx("span", { className: "dropdown_option_supporting", children: opt.supportingText })
4596
+ ] }),
4597
+ (opt.trailingIcon || !multiple && selected) && /* @__PURE__ */ jsx("span", { className: "dropdown_option_trailing", children: opt.trailingIcon ?? /* @__PURE__ */ jsx(Check, { size: iconSize.sm, "aria-hidden": "true" }) })
4598
+ ]
4599
+ }
4600
+ ),
4601
+ opt.showDivider && /* @__PURE__ */ jsx("hr", { className: "dropdown_option_divider" })
4602
+ ] }, opt.value);
4603
+ })
4604
+ }
4605
+ )
4606
+ ]
4467
4607
  }
4468
- )
4469
- ] })
4608
+ ),
4609
+ document.body
4610
+ )
4470
4611
  ] });
4471
4612
  };
4472
4613
  var pad = (n) => String(n).padStart(2, "0");
@@ -5966,9 +6107,11 @@ var Drawer = ({
5966
6107
  if (escapeDismissible) onClose?.();
5967
6108
  });
5968
6109
  if (open && !shouldRender) setShouldRender(true);
6110
+ const dimOwner = React11.useRef({}).current;
5969
6111
  const overlayStyle = useSpringPresence({
5970
6112
  visible: open,
5971
6113
  from: "translateY(0px)",
6114
+ onProgress: (progress) => reportOverlayDim(dimOwner, progress),
5972
6115
  onExitComplete: () => {
5973
6116
  setShouldRender(false);
5974
6117
  onExited?.();
@@ -5984,8 +6127,12 @@ var Drawer = ({
5984
6127
  });
5985
6128
  React11.useEffect(() => {
5986
6129
  if (!shouldRender) return;
6130
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
5987
6131
  lockBodyScroll();
5988
- return unlockBodyScroll;
6132
+ return () => {
6133
+ unlockBodyScroll();
6134
+ unregisterOverlayDim(dimOwner);
6135
+ };
5989
6136
  }, [shouldRender]);
5990
6137
  if (!open && !shouldRender) return null;
5991
6138
  if (typeof document === "undefined" || !isMounted) return null;
@@ -6074,11 +6221,13 @@ var Modal = ({
6074
6221
  });
6075
6222
  if (open && !shouldRender) setShouldRender(true);
6076
6223
  const reduced = useReducedMotion();
6224
+ const dimOwner = React11.useRef({}).current;
6077
6225
  const overlayStyle = useSpring({
6078
6226
  ...springEnterFrom(reduced),
6079
6227
  to: { opacity: open ? 1 : 0 },
6080
6228
  immediate: reduced,
6081
6229
  config: OVERLAY_SPRING_CONFIG,
6230
+ onChange: (result) => reportOverlayDim(dimOwner, Number(result.value.opacity)),
6082
6231
  onRest: (result) => {
6083
6232
  if (open || !result.finished) return;
6084
6233
  setShouldRender(false);
@@ -6096,8 +6245,12 @@ var Modal = ({
6096
6245
  });
6097
6246
  React11.useEffect(() => {
6098
6247
  if (!shouldRender) return;
6248
+ reportOverlayDim(dimOwner, reduced ? 1 : 0);
6099
6249
  lockBodyScroll();
6100
- return unlockBodyScroll;
6250
+ return () => {
6251
+ unlockBodyScroll();
6252
+ unregisterOverlayDim(dimOwner);
6253
+ };
6101
6254
  }, [shouldRender]);
6102
6255
  if (!open && !shouldRender) return null;
6103
6256
  if (typeof document === "undefined" || !isMounted) return null;