@bigtablet/design-system 3.11.0 → 3.13.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
@@ -243,6 +243,7 @@ function useAnchoredPosition({
243
243
  }, [open, placement, gap, padding, anchorRef, floatingRef]);
244
244
  return state;
245
245
  }
246
+ var SKIP_AUTOFOCUS_ATTR = "data-focus-trap-skip-autofocus";
246
247
  var FOCUSABLE_SELECTORS = [
247
248
  "a[href]",
248
249
  "button:not([disabled])",
@@ -263,8 +264,9 @@ function useFocusTrap(containerRef, isActive) {
263
264
  };
264
265
  let wasTabIndexAdded = false;
265
266
  const focusableElements = getFocusableElements();
266
- if (focusableElements.length > 0) {
267
- focusableElements[0].focus();
267
+ const initialTarget = Array.from(focusableElements).find((el) => !el.hasAttribute(SKIP_AUTOFOCUS_ATTR)) ?? focusableElements[0];
268
+ if (initialTarget) {
269
+ initialTarget.focus();
268
270
  } else {
269
271
  container.setAttribute("tabindex", "-1");
270
272
  wasTabIndexAdded = true;
@@ -542,15 +544,28 @@ var EmptyState = ({
542
544
  description,
543
545
  action,
544
546
  size = "md",
547
+ fillHeight,
545
548
  className,
546
549
  ...props
547
550
  }) => {
548
- return /* @__PURE__ */ jsxs("div", { className: cn("empty_state", `empty_state_size_${size}`, className), ...props, children: [
549
- illustration && /* @__PURE__ */ jsx("div", { className: "empty_state_illustration", "aria-hidden": "true", children: illustration }),
550
- title && /* @__PURE__ */ jsx("h3", { className: "empty_state_title", children: title }),
551
- description && /* @__PURE__ */ jsx("p", { className: "empty_state_description", children: description }),
552
- action && /* @__PURE__ */ jsx("div", { className: "empty_state_action", children: action })
553
- ] });
551
+ return /* @__PURE__ */ jsxs(
552
+ "div",
553
+ {
554
+ className: cn(
555
+ "empty_state",
556
+ `empty_state_size_${size}`,
557
+ fillHeight && "empty_state_fill_height",
558
+ className
559
+ ),
560
+ ...props,
561
+ children: [
562
+ illustration && /* @__PURE__ */ jsx("div", { className: "empty_state_illustration", "aria-hidden": "true", children: illustration }),
563
+ title && /* @__PURE__ */ jsx("h3", { className: "empty_state_title", children: title }),
564
+ description && /* @__PURE__ */ jsx("p", { className: "empty_state_description", children: description }),
565
+ action && /* @__PURE__ */ jsx("div", { className: "empty_state_action", children: action })
566
+ ]
567
+ }
568
+ );
554
569
  };
555
570
  var DEFAULT_ICON_SIZE = {
556
571
  page: 48,
@@ -667,11 +682,12 @@ var BottomNavSpacer = ({ className, ...props }) => {
667
682
  var Breadcrumb = ({
668
683
  items,
669
684
  separator,
685
+ navLabel = "\uD604\uC7AC \uC704\uCE58",
670
686
  className,
671
687
  ...props
672
688
  }) => {
673
689
  const sep = separator ?? /* @__PURE__ */ jsx(ChevronRight, { size: iconSize.xs, "aria-hidden": "true" });
674
- return /* @__PURE__ */ jsx("nav", { "aria-label": "Breadcrumb", className: cn("breadcrumb", className), ...props, children: /* @__PURE__ */ jsx("ol", { className: "breadcrumb_list", children: items.map((item, idx) => {
690
+ return /* @__PURE__ */ jsx("nav", { "aria-label": navLabel, className: cn("breadcrumb", className), ...props, children: /* @__PURE__ */ jsx("ol", { className: "breadcrumb_list", children: items.map((item, idx) => {
675
691
  const isLast = idx === items.length - 1;
676
692
  return (
677
693
  // biome-ignore lint/suspicious/noArrayIndexKey: breadcrumb items have stable order
@@ -1419,7 +1435,7 @@ var Popover = ({
1419
1435
  ref: popoverRef,
1420
1436
  role: "dialog",
1421
1437
  tabIndex: -1,
1422
- "aria-label": ariaLabelledby ? ariaLabel : ariaLabel ?? "Dialog",
1438
+ "aria-label": ariaLabel,
1423
1439
  "aria-labelledby": ariaLabelledby,
1424
1440
  style,
1425
1441
  className: cn("popover", className),
@@ -2433,6 +2449,29 @@ var MediaCard = ({
2433
2449
  ] })
2434
2450
  ] });
2435
2451
  };
2452
+ var Prose = ({ size = "md", className, children, ref, ...props }) => {
2453
+ const rootRef = React16.useRef(null);
2454
+ React16.useImperativeHandle(ref, () => rootRef.current, []);
2455
+ useSafeLayoutEffect(() => {
2456
+ const root = rootRef.current;
2457
+ if (!root) return;
2458
+ const targets = Array.from(root.querySelectorAll("pre, table"));
2459
+ const sync = () => {
2460
+ for (const el of targets) {
2461
+ if (el.scrollWidth > el.clientWidth) el.setAttribute("tabindex", "0");
2462
+ else el.removeAttribute("tabindex");
2463
+ }
2464
+ };
2465
+ sync();
2466
+ if (typeof ResizeObserver === "undefined") return;
2467
+ const observer = new ResizeObserver(sync);
2468
+ observer.observe(root);
2469
+ for (const el of targets) observer.observe(el);
2470
+ return () => observer.disconnect();
2471
+ }, [children]);
2472
+ return /* @__PURE__ */ jsx("div", { ref: rootRef, className: cn("prose", `prose_size_${size}`, className), ...props, children });
2473
+ };
2474
+ Prose.displayName = "Prose";
2436
2475
  var Skeleton = ({
2437
2476
  variant = "text",
2438
2477
  width,
@@ -2873,7 +2912,7 @@ var LinearProgress = ({
2873
2912
  }
2874
2913
  );
2875
2914
  };
2876
- var Spinner = ({ size = 24, ariaLabel = "Loading" }) => {
2915
+ var Spinner = ({ size = 24, ariaLabel = "\uB85C\uB529 \uC911" }) => {
2877
2916
  return /* @__PURE__ */ jsx(
2878
2917
  "span",
2879
2918
  {
@@ -2951,7 +2990,8 @@ var ToastItemComponent = ({ item, onRemove, closeAriaLabel }) => {
2951
2990
  var ToastProvider = ({
2952
2991
  children,
2953
2992
  maxCount = 5,
2954
- closeAriaLabel = "Close"
2993
+ closeAriaLabel = "\uB2EB\uAE30",
2994
+ regionLabel = "\uC54C\uB9BC"
2955
2995
  }) => {
2956
2996
  const [toasts, setToasts] = React16.useState([]);
2957
2997
  const isMounted = useIsMounted();
@@ -2977,7 +3017,7 @@ var ToastProvider = ({
2977
3017
  role: "region",
2978
3018
  "aria-live": "polite",
2979
3019
  "aria-atomic": "false",
2980
- "aria-label": "Notifications",
3020
+ "aria-label": regionLabel,
2981
3021
  children: toasts.map((item) => /* @__PURE__ */ jsx(
2982
3022
  ToastItemComponent,
2983
3023
  {
@@ -3018,7 +3058,7 @@ var TopLoading = ({
3018
3058
  color,
3019
3059
  height = 3,
3020
3060
  isLoading = true,
3021
- ariaLabel = "Page loading"
3061
+ ariaLabel = "\uD398\uC774\uC9C0 \uB85C\uB529 \uC911"
3022
3062
  }) => {
3023
3063
  if (!isLoading) return null;
3024
3064
  const isIndeterminate = progress === void 0;
@@ -3050,7 +3090,7 @@ var Dropdown = (props) => {
3050
3090
  const {
3051
3091
  id,
3052
3092
  label,
3053
- placeholder = "Select\u2026",
3093
+ placeholder = "\uC120\uD0DD\u2026",
3054
3094
  options,
3055
3095
  disabled,
3056
3096
  size = "md",
@@ -3399,11 +3439,11 @@ var DatePicker = ({
3399
3439
  disabled,
3400
3440
  fullWidth = true,
3401
3441
  width,
3402
- yearLabel = "Year",
3403
- monthLabel = "Month",
3404
- dayLabel = "Day",
3405
- minDateSrFormat = "Minimum date: {date}",
3406
- selectableRangeUntilTodaySrText = "Selectable up to today"
3442
+ yearLabel = "\uB144",
3443
+ monthLabel = "\uC6D4",
3444
+ dayLabel = "\uC77C",
3445
+ minDateSrFormat = "\uCD5C\uC18C \uB0A0\uC9DC: {date}",
3446
+ selectableRangeUntilTodaySrText = "\uC624\uB298\uAE4C\uC9C0 \uC120\uD0DD \uAC00\uB2A5"
3407
3447
  }) => {
3408
3448
  const groupId = React16.useId();
3409
3449
  const constraintId = React16.useId();
@@ -3582,7 +3622,7 @@ var DatePicker = ({
3582
3622
  ] });
3583
3623
  };
3584
3624
  var FileInput = ({
3585
- label = "Choose file",
3625
+ label = "\uD30C\uC77C \uC120\uD0DD",
3586
3626
  onFiles,
3587
3627
  supportingText,
3588
3628
  preview = false,
@@ -4413,7 +4453,7 @@ var Textarea = ({
4413
4453
  };
4414
4454
  Textarea.displayName = "Textarea";
4415
4455
  var ClearIcon = () => /* @__PURE__ */ jsx(X, { size: iconSize.lg, "aria-hidden": "true" });
4416
- var DEFAULT_PASSWORD_TOGGLE_LABELS = { show: "Show password", hide: "Hide password" };
4456
+ var DEFAULT_PASSWORD_TOGGLE_LABELS = { show: "\uBE44\uBC00\uBC88\uD638 \uD45C\uC2DC", hide: "\uBE44\uBC00\uBC88\uD638 \uC228\uAE30\uAE30" };
4417
4457
  var TextField = ({
4418
4458
  id,
4419
4459
  label,
@@ -4421,6 +4461,7 @@ var TextField = ({
4421
4461
  supportingText,
4422
4462
  error,
4423
4463
  success,
4464
+ identifier,
4424
4465
  leadingIcon,
4425
4466
  trailingIcon,
4426
4467
  leadingAction,
@@ -4428,6 +4469,7 @@ var TextField = ({
4428
4469
  showPasswordToggle,
4429
4470
  passwordToggleLabels,
4430
4471
  clearable,
4472
+ clearLabel = "\uC9C0\uC6B0\uAE30",
4431
4473
  type,
4432
4474
  fullWidth,
4433
4475
  size = "md",
@@ -4507,7 +4549,7 @@ var TextField = ({
4507
4549
  type: "button",
4508
4550
  className: "text_field_clear",
4509
4551
  onClick: handleClear,
4510
- "aria-label": "Clear",
4552
+ "aria-label": clearLabel,
4511
4553
  disabled: props.disabled,
4512
4554
  children: /* @__PURE__ */ jsx(ClearIcon, {})
4513
4555
  }
@@ -4529,7 +4571,7 @@ var TextField = ({
4529
4571
  {
4530
4572
  id: inputId,
4531
4573
  ref,
4532
- className: "text_field_input",
4574
+ className: cn("text_field_input", identifier && "text_field_input_identifier"),
4533
4575
  "aria-invalid": isError,
4534
4576
  "aria-describedby": helperId,
4535
4577
  "aria-label": !showLabel ? label : void 0,
@@ -4661,14 +4703,15 @@ var Pagination = ({
4661
4703
  totalPages,
4662
4704
  onPageChange,
4663
4705
  onChange,
4664
- prevLabel = "Previous page",
4665
- nextLabel = "Next page"
4706
+ prevLabel = "\uC774\uC804 \uD398\uC774\uC9C0",
4707
+ nextLabel = "\uB2E4\uC74C \uD398\uC774\uC9C0",
4708
+ navLabel = "\uD398\uC774\uC9C0 \uC774\uB3D9"
4666
4709
  }) => {
4667
4710
  const emit = onPageChange ?? onChange;
4668
4711
  const prevDisabled = page <= 1;
4669
4712
  const nextDisabled = page >= totalPages;
4670
4713
  const items = React16.useMemo(() => getPaginationItems(page, totalPages), [page, totalPages]);
4671
- return /* @__PURE__ */ jsxs("nav", { className: "pagination", "aria-label": "Pagination", children: [
4714
+ return /* @__PURE__ */ jsxs("nav", { className: "pagination", "aria-label": navLabel, children: [
4672
4715
  /* @__PURE__ */ jsx(
4673
4716
  "button",
4674
4717
  {
@@ -4725,25 +4768,38 @@ var Drawer = ({
4725
4768
  title,
4726
4769
  footer,
4727
4770
  closeOnOverlay = true,
4771
+ dismissible,
4728
4772
  showCloseIcon = true,
4729
4773
  closeLabel = "\uB2EB\uAE30",
4730
4774
  ariaLabel,
4775
+ onExited,
4731
4776
  children,
4732
4777
  className,
4733
4778
  ...props
4734
4779
  }) => {
4780
+ const lastContentRef = React16.useRef({ children, title, footer });
4781
+ if (open) lastContentRef.current = { children, title, footer };
4782
+ const content = open ? { children, title, footer } : lastContentRef.current;
4783
+ const overlayDismissible = dismissible ?? closeOnOverlay;
4784
+ const escapeDismissible = dismissible ?? true;
4735
4785
  const panelRef = React16.useRef(null);
4786
+ const pressedOverlayRef = React16.useRef(false);
4736
4787
  const titleId = React16.useId();
4737
4788
  const [shouldRender, setShouldRender] = React16.useState(open);
4738
4789
  const reduced = useReducedMotion();
4739
4790
  const isMounted = useIsMounted();
4740
4791
  useFocusTrap(panelRef, open && isMounted);
4741
- useOverlayEscape(open && isMounted, () => onClose?.());
4792
+ useOverlayEscape(open && isMounted, () => {
4793
+ if (escapeDismissible) onClose?.();
4794
+ });
4742
4795
  if (open && !shouldRender) setShouldRender(true);
4743
4796
  const overlayStyle = useSpringPresence({
4744
4797
  visible: open,
4745
4798
  from: "translateY(0px)",
4746
- onExitComplete: () => setShouldRender(false)
4799
+ onExitComplete: () => {
4800
+ setShouldRender(false);
4801
+ onExited?.();
4802
+ }
4747
4803
  });
4748
4804
  const slideFrom = SLIDE_FROM[placement];
4749
4805
  const slideRest = placement === "bottom" ? "translateY(0%)" : "translateX(0%)";
@@ -4760,7 +4816,7 @@ var Drawer = ({
4760
4816
  }, [open]);
4761
4817
  if (!open && !shouldRender) return null;
4762
4818
  if (typeof document === "undefined" || !isMounted) return null;
4763
- const hasTitle = !!title;
4819
+ const hasTitle = !!content.title;
4764
4820
  const sizeValue = typeof size === "number" ? `${size}px` : size;
4765
4821
  const panelSizeStyle = placement === "bottom" ? { height: sizeValue } : { width: sizeValue };
4766
4822
  return createPortal(
@@ -4772,8 +4828,16 @@ var Drawer = ({
4772
4828
  role: "dialog",
4773
4829
  "aria-modal": "true",
4774
4830
  "aria-labelledby": hasTitle && !ariaLabel ? titleId : void 0,
4775
- "aria-label": !hasTitle ? ariaLabel ?? "Dialog" : ariaLabel,
4776
- onClick: () => closeOnOverlay && onClose?.(),
4831
+ "aria-label": ariaLabel,
4832
+ onPointerDown: (e) => {
4833
+ pressedOverlayRef.current = e.target === e.currentTarget;
4834
+ },
4835
+ onClick: (e) => {
4836
+ if (!overlayDismissible) return;
4837
+ if (e.target !== e.currentTarget) return;
4838
+ if (!pressedOverlayRef.current) return;
4839
+ onClose?.();
4840
+ },
4777
4841
  children: /* @__PURE__ */ jsxs(
4778
4842
  animated.div,
4779
4843
  {
@@ -4781,8 +4845,6 @@ var Drawer = ({
4781
4845
  ...props,
4782
4846
  className: cn("drawer_panel", className),
4783
4847
  style: { ...props.style, ...panelStyle, ...panelSizeStyle },
4784
- role: "document",
4785
- onClick: (e) => e.stopPropagation(),
4786
4848
  onKeyDown: (e) => {
4787
4849
  if (e.key !== "Escape") e.stopPropagation();
4788
4850
  },
@@ -4797,9 +4859,12 @@ var Drawer = ({
4797
4859
  children: /* @__PURE__ */ jsx(X, { size: iconSize.md, "aria-hidden": "true" })
4798
4860
  }
4799
4861
  ),
4800
- title && /* @__PURE__ */ jsx("div", { className: "drawer_header", children: /* @__PURE__ */ jsx("h2", { id: titleId, className: "drawer_title", children: title }) }),
4801
- children && /* @__PURE__ */ jsx("div", { className: "drawer_body", children }),
4802
- footer && /* @__PURE__ */ jsx("div", { className: "drawer_footer", children: footer })
4862
+ content.title && /* @__PURE__ */ jsx("div", { className: "drawer_header", children: /* @__PURE__ */ jsx("h2", { id: titleId, className: "drawer_title", children: content.title }) }),
4863
+ content.children && // 본문이 스크롤 컨테이너라 키보드로도 스크롤할 있어야 한다(axe
4864
+ // `scrollable-region-focusable`). 다만 초기 포커스 대상에서는 제외한다 - 안쪽에
4865
+ // 첫 입력이 있는데 빈 wrapper 에 포커스가 놓이면 열자마자 어디에 있는지 알 수 없다.
4866
+ /* @__PURE__ */ jsx("div", { className: "drawer_body", tabIndex: 0, "data-focus-trap-skip-autofocus": "", children: content.children }),
4867
+ content.footer && /* @__PURE__ */ jsx("div", { className: "drawer_footer", children: content.footer })
4803
4868
  ]
4804
4869
  }
4805
4870
  )
@@ -4813,6 +4878,7 @@ var Modal = ({
4813
4878
  open,
4814
4879
  onClose,
4815
4880
  closeOnOverlay = true,
4881
+ dismissible,
4816
4882
  width = 480,
4817
4883
  title,
4818
4884
  description,
@@ -4823,14 +4889,23 @@ var Modal = ({
4823
4889
  children,
4824
4890
  className,
4825
4891
  ariaLabel,
4892
+ onExited,
4826
4893
  ...props
4827
4894
  }) => {
4895
+ const lastContentRef = React16.useRef({ children, title, description, footer });
4896
+ if (open) lastContentRef.current = { children, title, description, footer };
4897
+ const content = open ? { children, title, description, footer } : lastContentRef.current;
4898
+ const overlayDismissible = dismissible ?? closeOnOverlay;
4899
+ const escapeDismissible = dismissible ?? true;
4828
4900
  const panelRef = React16.useRef(null);
4901
+ const pressedOverlayRef = React16.useRef(false);
4829
4902
  const titleId = React16.useId();
4830
4903
  const [shouldRender, setShouldRender] = React16.useState(open);
4831
4904
  const isMounted = useIsMounted();
4832
4905
  useFocusTrap(panelRef, open && isMounted);
4833
- useOverlayEscape(open && isMounted, () => onClose?.());
4906
+ useOverlayEscape(open && isMounted, () => {
4907
+ if (escapeDismissible) onClose?.();
4908
+ });
4834
4909
  if (open && !shouldRender) setShouldRender(true);
4835
4910
  const reduced = useReducedMotion();
4836
4911
  const overlayStyle = useSpring({
@@ -4838,7 +4913,9 @@ var Modal = ({
4838
4913
  immediate: reduced,
4839
4914
  config: { tension: 280, friction: 28, clamp: !open },
4840
4915
  onRest: (result) => {
4841
- if (!open && result.finished) setShouldRender(false);
4916
+ if (open || !result.finished) return;
4917
+ setShouldRender(false);
4918
+ onExited?.();
4842
4919
  }
4843
4920
  });
4844
4921
  const panelStyle = useSpring({
@@ -4854,7 +4931,7 @@ var Modal = ({
4854
4931
  }, [open]);
4855
4932
  if (!open && !shouldRender) return null;
4856
4933
  if (typeof document === "undefined" || !isMounted) return null;
4857
- const hasTitle = !!title;
4934
+ const hasTitle = !!content.title;
4858
4935
  return createPortal(
4859
4936
  /* @__PURE__ */ jsx(
4860
4937
  animated.div,
@@ -4864,8 +4941,16 @@ var Modal = ({
4864
4941
  role: "dialog",
4865
4942
  "aria-modal": "true",
4866
4943
  "aria-labelledby": hasTitle && !ariaLabel ? titleId : void 0,
4867
- "aria-label": !hasTitle ? ariaLabel ?? "Dialog" : ariaLabel,
4868
- onClick: () => closeOnOverlay && onClose?.(),
4944
+ "aria-label": ariaLabel,
4945
+ onPointerDown: (e) => {
4946
+ pressedOverlayRef.current = e.target === e.currentTarget;
4947
+ },
4948
+ onClick: (e) => {
4949
+ if (!overlayDismissible) return;
4950
+ if (e.target !== e.currentTarget) return;
4951
+ if (!pressedOverlayRef.current) return;
4952
+ onClose?.();
4953
+ },
4869
4954
  children: /* @__PURE__ */ jsxs(
4870
4955
  animated.div,
4871
4956
  {
@@ -4873,26 +4958,21 @@ var Modal = ({
4873
4958
  ...props,
4874
4959
  className: cn("modal_panel", className),
4875
4960
  style: { ...props.style, ...panelStyle, width },
4876
- role: "document",
4877
- onClick: (e) => e.stopPropagation(),
4878
4961
  onKeyDown: (e) => {
4879
4962
  if (e.key !== "Escape") e.stopPropagation();
4880
4963
  },
4881
4964
  children: [
4882
- showCloseIcon && onClose && /* @__PURE__ */ jsx(
4883
- "button",
4884
- {
4885
- type: "button",
4886
- className: "modal_close",
4887
- onClick: onClose,
4888
- "aria-label": closeLabel,
4889
- children: /* @__PURE__ */ jsx(X, { size: iconSize.md, "aria-hidden": "true" })
4890
- }
4891
- ),
4892
- title && /* @__PURE__ */ jsx("h2", { id: titleId, className: "modal_title", children: title }),
4893
- description && /* @__PURE__ */ jsx("div", { className: "modal_description", children: description }),
4894
- children && /* @__PURE__ */ jsx("div", { className: "modal_body", children }),
4895
- footer && /* @__PURE__ */ jsx("div", { className: cn("modal_footer", `modal_footer_${footerAlign}`), children: footer })
4965
+ showCloseIcon && onClose && /* @__PURE__ */ jsx("button", { type: "button", className: "modal_close", onClick: onClose, "aria-label": closeLabel, children: /* @__PURE__ */ jsx(X, { size: iconSize.md, "aria-hidden": "true" }) }),
4966
+ content.title && /* @__PURE__ */ jsx("h2", { id: titleId, className: "modal_title", children: content.title }),
4967
+ content.description && /* @__PURE__ */ jsx("div", { className: "modal_description", children: content.description }),
4968
+ content.children && // 본문이 스크롤 컨테이너라 키보드로도 스크롤할 수 있어야 한다(axe
4969
+ // `scrollable-region-focusable`). 넘치는지 여부를 측정해 조건부로 주는 방법도 있지만
4970
+ // ResizeObserver 를 들일 만큼의 이득이 없고, 포커스 트랩 안에서 탭 정지 하나가
4971
+ // 늘어나는 정도의 비용이다.
4972
+ // 다만 초기 포커스 대상에서는 제외한다 - 안쪽에 입력이 있는데 빈 wrapper 에
4973
+ // 포커스가 놓이면 열자마자 어디에 있는지 알 수 없다.
4974
+ /* @__PURE__ */ jsx("div", { className: "modal_body", tabIndex: 0, "data-focus-trap-skip-autofocus": "", children: content.children }),
4975
+ content.footer && /* @__PURE__ */ jsx("div", { className: cn("modal_footer", `modal_footer_${footerAlign}`), children: content.footer })
4896
4976
  ]
4897
4977
  }
4898
4978
  )
@@ -5078,4 +5158,4 @@ var Stack = ({
5078
5158
  );
5079
5159
  };
5080
5160
 
5081
- export { Accordion, AlertProvider, Avatar, Badge, BottomNav, BottomNavItem, BottomNavSpacer, Breadcrumb, Button, Card, Checkbox, Chip, Container, DatePicker, Divider, Drawer, Dropdown, EmptyState, ErrorState, FileInput, Grid, Hero, Icon, IconButton, ImageCropper, LinearProgress, ListItem, MediaCard, Menu, Modal, NavBar, NavLink, OtpInput, Pagination, Popover, Radio, RadioGroup, Section, Sidebar, SidebarItem, SidebarSection, Skeleton, Spinner, Stack, Tab, TabList, TabPanel, Table, Tabs, TextField, Textarea, ThemeProvider, ToastProvider, Toggle, Tooltip, TopLoading, a11y, baseBorderWidth, baseColors, baseTypography, borderWidth, breakpoints, cn, colors, elevation, iconSize, motion, opacity, radius, skeleton, spacing, typography, useAlert, useFocusTrap, useRadioGroupContext, useReducedMotion, useSpringHover, useSpringPresence, useTheme, useToast, zIndex };
5161
+ export { Accordion, AlertProvider, Avatar, Badge, BottomNav, BottomNavItem, BottomNavSpacer, Breadcrumb, Button, Card, Checkbox, Chip, Container, DatePicker, Divider, Drawer, Dropdown, EmptyState, ErrorState, FileInput, Grid, Hero, Icon, IconButton, ImageCropper, LinearProgress, ListItem, MediaCard, Menu, Modal, NavBar, NavLink, OtpInput, Pagination, Popover, Prose, Radio, RadioGroup, Section, Sidebar, SidebarItem, SidebarSection, Skeleton, Spinner, Stack, Tab, TabList, TabPanel, Table, Tabs, TextField, Textarea, ThemeProvider, ToastProvider, Toggle, Tooltip, TopLoading, a11y, baseBorderWidth, baseColors, baseTypography, borderWidth, breakpoints, cn, colors, elevation, iconSize, motion, opacity, radius, skeleton, spacing, typography, useAlert, useFocusTrap, useRadioGroupContext, useReducedMotion, useSpringHover, useSpringPresence, useTheme, useToast, zIndex };
@@ -12,7 +12,9 @@ $focus_ring_success: var(--bt-focus-ring-success);
12
12
  // WCAG 2.1 SC 2.5.5 권장 최소: 44×44
13
13
 
14
14
  $tap_min_size: 44px; // a11y 최소 권장
15
- $tap_target_dense: 32px; // 데스크탑 인라인 폼, 컴팩트 UI
15
+ $tap_target_dense: 32px; // 데스크탑 인라인 폼, 컴팩트 UI. 인터랙티브 컨트롤의 하한이다
16
+ // - 터치 입력이 있는 화면에서는 responsive_height 로 승급할 것
17
+ // - 예외: 마우스 전용 내부 도구(admin 등)는 28px 까지 허용
16
18
  $tap_target_compact: 40px; // 데스크탑 기본
17
19
  $tap_target_comfortable: 48px; // 모바일/터치 기본 (44 초과)
18
20
  $tap_target_spacious: 56px; // 강조 CTA, 풀스크린 액션
@@ -44,3 +46,48 @@ $tap_target_spacious: 56px; // 강조 CTA, 풀스크린 액션
44
46
  }
45
47
  }
46
48
  }
49
+
50
+ // ── Visually hidden ───────────────────────────────────────────────────────
51
+ // 시각적으로 숨기되 보조기기에는 남긴다. 라이브 리전, 아이콘 버튼의 텍스트 라벨,
52
+ // 스크린리더 전용 표 헤더 등. `display: none` · `visibility: hidden` 은 접근성 트리에서도
53
+ // 빠지므로 대신 쓸 수 없다.
54
+ //
55
+ // `clip: rect()` 는 CSS Masking 에서 deprecated 지만 구형 폴백으로 함께 둔다. `clip-path`
56
+ // 단독은 일부 조합에서 요소가 잘리지 않는다.
57
+ @mixin visually_hidden {
58
+ position: absolute;
59
+ width: 1px;
60
+ height: 1px;
61
+ padding: 0;
62
+ margin: -1px;
63
+ overflow: hidden;
64
+ clip: rect(0 0 0 0);
65
+ clip-path: inset(50%);
66
+ white-space: nowrap;
67
+ border: 0;
68
+ }
69
+
70
+ // 조건부로 숨기는 자리(브레이크포인트·상태에 따라 다시 보여야 하는 라벨 등)에서 위를 되돌린다.
71
+ // 짝을 손으로 쓰면 한쪽에 속성을 추가할 때 다른 쪽을 빠뜨린다 - `clip-path` 를 되돌리지 않아
72
+ // 라벨이 계속 잘려 있던 것이 실제로 있었던 실수다.
73
+ @mixin visually_hidden_reset {
74
+ position: static;
75
+ width: auto;
76
+ height: auto;
77
+ padding: 0;
78
+ margin: 0;
79
+ overflow: visible;
80
+ clip: auto;
81
+ clip-path: none;
82
+ white-space: normal;
83
+ }
84
+
85
+ // 포커스를 받으면 드러난다. 스킵 링크("본문으로 건너뛰기") 패턴 - 평소엔 안 보이지만
86
+ // 키보드 사용자가 Tab 을 누르면 나타나야 한다.
87
+ @mixin visually_hidden_focusable {
88
+ @include visually_hidden;
89
+
90
+ &:focus-visible {
91
+ @include visually_hidden_reset;
92
+ }
93
+ }
@@ -1,3 +1,6 @@
1
+ @use "sass:list";
2
+ @use "../colors" as colors;
3
+
1
4
  // ── Base border-width ────────────────────────────────────────────────────────
2
5
 
3
6
  $border_width_0: 0px;
@@ -10,3 +13,29 @@ $border_width_none: $border_width_0;
10
13
  $border_width_standard: $border_width_1;
11
14
  $border_width_thick: $border_width_2;
12
15
  $border_width_indicator: $border_width_2;
16
+
17
+ // ── Hairline mixin ───────────────────────────────────────────────────────────
18
+ //
19
+ // 1px 경계선용 단축. `border: $border_width_standard solid $color_border_default` 는
20
+ // `border: 1px solid #E5E5E5` 보다 길어서, 실측상 소비 앱 SCSS 에 `1px` 하드코딩이
21
+ // 232 건 남아 있었다. 짧게 쓸 수 있어야 실제로 토큰을 쓴다.
22
+ //
23
+ // @example
24
+ // ```scss
25
+ // .card { @include token.hairline; } // 사방
26
+ // .header { @include token.hairline(bottom); } // 한 면
27
+ // .active { @include token.hairline(left, token.$color_border_hover); }
28
+ // ```
29
+
30
+
31
+ @mixin hairline($side: all, $color: colors.$color_border_default) {
32
+ @if $side == all {
33
+ border: $border_width_standard solid $color;
34
+ } @else if list.index(top right bottom left, $side) {
35
+ border-#{$side}: $border_width_standard solid $color;
36
+ } @else {
37
+ // 검증이 없으면 `hairline(letf)` 같은 오타가 컴파일을 통과해 `border-letf` 라는
38
+ // 무효 속성을 조용히 만든다. 소비 앱에 권장하는 공개 API 라 여기서 끊는다.
39
+ @error "token.hairline: $side 는 all · top · right · bottom · left 중 하나여야 합니다 (받은 값: #{$side})";
40
+ }
41
+ }
@@ -145,8 +145,37 @@ $letter_spacing_tight: 0.32px;
145
145
  white-space: nowrap;
146
146
  }
147
147
 
148
+ // 한글 줄바꿈. `word-break: break-word` 는 어절 중간에서 끊어 "안녕하세 / 요" 를 만든다.
149
+ // 표준값이 아니고, 명세상 `word-break: normal` + `overflow-wrap: anywhere` 와 같게 동작한다.
150
+ // keep-all 이 어절을 지키고, 끊을 수 없는 긴 영문·URL 만 overflow-wrap 이 처리한다.
151
+ // 텍스트를 줄바꿈하는 모든 컴포넌트는 이 믹스인을 쓴다 (Button·Toast·Tooltip).
152
+ //
153
+ // `break-word` 가 아니라 `anywhere` 를 쓴다. 눈에 보이는 줄바꿈 지점은 둘이 완전히 같지만
154
+ // (120·60px 폭에서 한글·혼합 문자열 실측 결과 동일), min-content 폭이 다르다 —
155
+ // break-word 는 최장 단어(122px), anywhere 는 한 글자(13px). flex item 의 기본
156
+ // min-width: auto 는 min-content 아래로 줄지 못하므로 break-word 면 좁은 컨테이너를 넘친다
157
+ // (실측: 120px 컨테이너에서 98px 초과). anywhere 는 min-width: 0 없이도 넘치지 않고,
158
+ // 텍스트가 inline-flex 의 직접 자식인 vanilla `.bt-button` 처럼 익명 flex item 이라
159
+ // min-width 를 줄 선택자조차 없는 경우까지 함께 해결한다.
160
+ @mixin wrap_keep_all {
161
+ overflow-wrap: anywhere;
162
+ word-break: keep-all;
163
+ }
164
+
148
165
  // ── Numeric (v3.1) ────────────────────────────────────────────────────────
149
166
  // 매출/지표 표시할 때 숫자 자릿수 정렬.
150
167
 
151
168
  @mixin tabular_nums { font-variant-numeric: tabular-nums; }
152
169
  @mixin slashed_zero { font-variant-numeric: slashed-zero; }
170
+
171
+ // ── Legibility ────────────────────────────────────────────────────────────
172
+ // Pretendard 기본 글자꼴은 l · I · 1 이 거의 같고 0 과 O 도 헷갈린다. 사용자가 한 글자씩
173
+ // 옮겨 적는 값(아이디·인증코드·시리얼·사업자번호)에서 오탈자를 만든다.
174
+ // cv05 = l 에 꼬리, cv08 = I 에 세리프. Pretendard 가 아닌 폴백 폰트는 두 feature 를
175
+ // 그냥 무시하므로 안전하다.
176
+ // 주의: slashed-zero 를 포함하므로 tabular_nums / slashed_zero 와 같은 선택자에서
177
+ // 겹쳐 쓰면 안 된다 - 셋 다 font-variant-numeric 이라 뒤에 온 것만 남는다.
178
+ @mixin legible_identifiers {
179
+ font-feature-settings: "cv05" 1, "cv08" 1;
180
+ font-variant-numeric: slashed-zero;
181
+ }