sellmate-design-system-react 9.0.0-beta.94 → 9.0.0-beta.97

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
@@ -7702,6 +7702,13 @@ var TREE_LABEL_TYPOGRAPHY = {
7702
7702
  rowHeight: "h-[32px]"
7703
7703
  }
7704
7704
  };
7705
+ var TREE_BRANCH_MOTION = { duration: 200, easing: "cubic-bezier(0, 0, 0.2, 1)" };
7706
+ function canAnimateTreeBranch() {
7707
+ if (typeof window === "undefined" || typeof HTMLElement.prototype.animate !== "function") {
7708
+ return false;
7709
+ }
7710
+ return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
7711
+ }
7705
7712
  var TREE_ROW_CLASS = "group/tree-item box-border flex w-full items-center gap-(--cmp-tree-item-rowGap) overflow-hidden bg-(--cmp-tree-item-bg-default) px-(--cmp-tree-item-paddingX) text-left hover:bg-(--cmp-tree-item-bg-hover)";
7706
7713
  var TREE_ROW_BORDER_CLASS = "border-b-(length:--cmp-tree-item-border-width-default) border-(--cmp-tree-item-border-default)";
7707
7714
  var TREE_ROW_ACTIVE_CLASS = "bg-(--cmp-tree-item-bg-hover)";
@@ -15650,6 +15657,93 @@ function SCalendarBoard({
15650
15657
  );
15651
15658
  }
15652
15659
 
15660
+ // src/lib/row-motion.ts
15661
+ var EXPAND_MOTION = { duration: 220, easing: "ease-out" };
15662
+ var COLLAPSE_MOTION = { duration: 140, easing: "ease-in" };
15663
+ function sameKeys(a, b) {
15664
+ return a.size === b.size && [...a].every((key) => b.has(key));
15665
+ }
15666
+ function motionAllowed() {
15667
+ if (typeof window === "undefined" || typeof Element.prototype.animate !== "function")
15668
+ return false;
15669
+ return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
15670
+ }
15671
+ function startRowMotion({
15672
+ scrollEl,
15673
+ isInBlock,
15674
+ blockKeys,
15675
+ from,
15676
+ to
15677
+ }) {
15678
+ const spec = to === 1 ? EXPAND_MOTION : COLLAPSE_MOTION;
15679
+ const duration = spec.duration * Math.abs(to - from);
15680
+ if (duration < 1) return null;
15681
+ const trs = [
15682
+ ...scrollEl.querySelectorAll("tr[data-row-index], tr[data-expansion-row]")
15683
+ ].map((tr) => ({ tr, inBlock: isInBlock(tr), height: tr.getBoundingClientRect().height }));
15684
+ const viewBottom = scrollEl.scrollTop + scrollEl.clientHeight;
15685
+ const openAt = (offset) => from + (to - from) * offset;
15686
+ const translate = (shift2, s) => `translateY(${-shift2 * (1 - s)}px)`;
15687
+ const timing = {
15688
+ duration,
15689
+ easing: spec.easing,
15690
+ // 접힘은 남겨 둔 행이 빠질 때까지 끝 위치를 붙잡는다 — 놓으면 그 사이 한 프레임 제자리로 튄다
15691
+ fill: to === 0 ? "forwards" : "none"
15692
+ };
15693
+ const animations = [];
15694
+ let shift = 0;
15695
+ let blockHeight = 0;
15696
+ let y = 0;
15697
+ trs.forEach(({ tr, inBlock, height }, i) => {
15698
+ if (inBlock && !trs[i - 1]?.inBlock) {
15699
+ blockHeight = 0;
15700
+ for (let j = i; j < trs.length && trs[j].inBlock; j++) blockHeight += trs[j].height;
15701
+ shift += blockHeight;
15702
+ y = 0;
15703
+ }
15704
+ if (shift === 0 || tr.offsetTop - shift > viewBottom) return;
15705
+ if (!inBlock) {
15706
+ animations.push(
15707
+ tr.animate(
15708
+ [{ transform: translate(shift, from) }, { transform: translate(shift, to) }],
15709
+ timing
15710
+ )
15711
+ );
15712
+ return;
15713
+ }
15714
+ const rowY = y;
15715
+ const clipAt = (s) => Math.min(height, Math.max(0, blockHeight * (1 - s) - rowY));
15716
+ const bends = [1 - rowY / blockHeight, 1 - (rowY + height) / blockHeight].map((s) => (s - from) / (to - from)).filter((offset) => offset > 0 && offset < 1);
15717
+ const offsets = [0, ...bends, 1].sort((a, b) => a - b);
15718
+ animations.push(
15719
+ tr.animate(
15720
+ offsets.map((offset) => ({
15721
+ offset,
15722
+ transform: translate(shift, openAt(offset)),
15723
+ clipPath: `inset(${clipAt(openAt(offset))}px 0 0 0)`
15724
+ })),
15725
+ timing
15726
+ )
15727
+ );
15728
+ y += height;
15729
+ });
15730
+ if (animations.length === 0) return null;
15731
+ return {
15732
+ keys: blockKeys,
15733
+ to,
15734
+ done: false,
15735
+ finished: Promise.all(animations.map((animation) => animation.finished)).then(
15736
+ () => true,
15737
+ () => false
15738
+ ),
15739
+ openness: () => {
15740
+ const progress = animations[0].effect?.getComputedTiming().progress;
15741
+ return progress == null ? to : openAt(progress);
15742
+ },
15743
+ cancel: () => animations.forEach((animation) => animation.cancel())
15744
+ };
15745
+ }
15746
+
15653
15747
  // src/lib/row-metrics.ts
15654
15748
  function createRowMetrics(count, estimate) {
15655
15749
  const safeCount = Math.max(0, Math.floor(count));
@@ -16069,6 +16163,7 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16069
16163
  onSearchChange,
16070
16164
  searchDebounce = DEFAULT_SEARCH_DEBOUNCE,
16071
16165
  label,
16166
+ ariaLabel,
16072
16167
  labelPosition,
16073
16168
  subLabel,
16074
16169
  labelWidth,
@@ -16098,6 +16193,21 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16098
16193
  const [focusedIndex, setFocusedIndex] = useState(-1);
16099
16194
  const [expanded, setExpanded] = useState(() => /* @__PURE__ */ new Set());
16100
16195
  const [searchCollapsed, setSearchCollapsed] = useState(NO_SEARCH_COLLAPSED);
16196
+ const [treeMotion, setTreeMotion] = useState(null);
16197
+ const treeMotionTimerRef = useRef(null);
16198
+ const pendingCollapseRef = useRef(null);
16199
+ const cancelTreeMotion = () => {
16200
+ if (treeMotionTimerRef.current != null) clearTimeout(treeMotionTimerRef.current);
16201
+ treeMotionTimerRef.current = null;
16202
+ pendingCollapseRef.current = null;
16203
+ setTreeMotion(null);
16204
+ };
16205
+ useEffect(
16206
+ () => () => {
16207
+ if (treeMotionTimerRef.current != null) clearTimeout(treeMotionTimerRef.current);
16208
+ },
16209
+ []
16210
+ );
16101
16211
  const baseId = useId();
16102
16212
  const listboxId = `${baseId}-listbox`;
16103
16213
  const activeOptionId = `${baseId}-active-option`;
@@ -16165,6 +16275,7 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16165
16275
  const commitOpen = (next, valueForRules) => {
16166
16276
  setOpen(next);
16167
16277
  onOpenChange?.(next);
16278
+ cancelTreeMotion();
16168
16279
  if (next) {
16169
16280
  dismissedByPointerRef.current = false;
16170
16281
  alignSelectedTopRef.current = true;
@@ -16235,6 +16346,10 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16235
16346
  (groupValue) => treeSearching ? !searchCollapsed.has(groupValue) : expanded.has(groupValue),
16236
16347
  [expanded, searchCollapsed, treeSearching]
16237
16348
  );
16349
+ const isGroupShownExpanded = useCallback(
16350
+ (groupValue) => isGroupExpanded(groupValue) && !(treeMotion != null && !treeMotion.opening && treeMotion.group === groupValue),
16351
+ [isGroupExpanded, treeMotion]
16352
+ );
16238
16353
  const treeRows = useMemo(
16239
16354
  () => isTree ? visibleTreeRows(searched, (row) => isGroupExpanded(row.value)) : null,
16240
16355
  [isGroupExpanded, isTree, searched]
@@ -16524,6 +16639,7 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16524
16639
  const handleSearchChange = (next) => {
16525
16640
  setSearch(next);
16526
16641
  setSearchCollapsed(NO_SEARCH_COLLAPSED);
16642
+ cancelTreeMotion();
16527
16643
  };
16528
16644
  const handleToggleExpand = (groupValue) => {
16529
16645
  rangeAnchorRef.current = null;
@@ -16533,8 +16649,41 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16533
16649
  else next.add(groupValue);
16534
16650
  return next;
16535
16651
  };
16536
- if (treeSearching) setSearchCollapsed(toggle);
16537
- else setExpanded(toggle);
16652
+ const applyToggle = treeSearching ? () => setSearchCollapsed(toggle) : () => setExpanded(toggle);
16653
+ if (treeMotionTimerRef.current != null) clearTimeout(treeMotionTimerRef.current);
16654
+ treeMotionTimerRef.current = null;
16655
+ const pendingCollapse = pendingCollapseRef.current;
16656
+ pendingCollapseRef.current = null;
16657
+ if (treeMotion?.group === groupValue && !treeMotion.opening) {
16658
+ setTreeMotion({ group: groupValue, opening: true });
16659
+ treeMotionTimerRef.current = setTimeout(
16660
+ () => setTreeMotion(null),
16661
+ TREE_BRANCH_MOTION.duration
16662
+ );
16663
+ return;
16664
+ }
16665
+ pendingCollapse?.();
16666
+ if (!canAnimateTreeBranch()) {
16667
+ setTreeMotion(null);
16668
+ applyToggle();
16669
+ return;
16670
+ }
16671
+ if (isGroupExpanded(groupValue)) {
16672
+ setTreeMotion({ group: groupValue, opening: false });
16673
+ pendingCollapseRef.current = applyToggle;
16674
+ treeMotionTimerRef.current = setTimeout(() => {
16675
+ pendingCollapseRef.current = null;
16676
+ setTreeMotion(null);
16677
+ applyToggle();
16678
+ }, TREE_BRANCH_MOTION.duration);
16679
+ } else {
16680
+ applyToggle();
16681
+ setTreeMotion({ group: groupValue, opening: true });
16682
+ treeMotionTimerRef.current = setTimeout(
16683
+ () => setTreeMotion(null),
16684
+ TREE_BRANCH_MOTION.duration
16685
+ );
16686
+ }
16538
16687
  };
16539
16688
  const handleToggleGroup = (leafValues) => {
16540
16689
  if (leafValues.length === 0) return;
@@ -16644,7 +16793,7 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16644
16793
  const row = rowIndex != null ? filtered[rowIndex] : void 0;
16645
16794
  if (rowIndex == null || row == null) return;
16646
16795
  event.preventDefault();
16647
- const rowExpanded = row.isGroup && isGroupExpanded(row.value);
16796
+ const rowExpanded = row.isGroup && isGroupShownExpanded(row.value);
16648
16797
  if (event.key === "ArrowRight") {
16649
16798
  if (!row.isGroup) return;
16650
16799
  if (!rowExpanded) {
@@ -16717,6 +16866,7 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16717
16866
  style,
16718
16867
  children: [
16719
16868
  name && /* @__PURE__ */ jsx("input", { type: "hidden", name, value: selected.join(",") }),
16869
+ !label && ariaLabel && /* @__PURE__ */ jsx("span", { id: `${triggerId}-aria-label`, hidden: true, children: ariaLabel }),
16720
16870
  /* @__PURE__ */ jsxs(
16721
16871
  "div",
16722
16872
  {
@@ -16725,7 +16875,7 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16725
16875
  role: "combobox",
16726
16876
  tabIndex: effectiveDisabled ? -1 : 0,
16727
16877
  "aria-disabled": effectiveDisabled || void 0,
16728
- "aria-labelledby": label ? `${fieldLabelId(triggerId)} ${triggerId}` : triggerId,
16878
+ "aria-labelledby": label ? `${fieldLabelId(triggerId)} ${triggerId}` : ariaLabel ? `${triggerId}-aria-label ${triggerId}` : triggerId,
16729
16879
  "aria-haspopup": isTree ? "tree" : "listbox",
16730
16880
  "aria-expanded": open,
16731
16881
  "aria-controls": open ? listboxId : void 0,
@@ -16889,8 +17039,9 @@ var SSelectImpl = /* @__PURE__ */ forwardRef(function SSelect({
16889
17039
  setSize: isDepth || isTree ? void 0 : rowCount,
16890
17040
  multi,
16891
17041
  tree: isTree,
16892
- isExpanded: isGroupExpanded,
17042
+ isExpanded: isGroupShownExpanded,
16893
17043
  onToggleExpand: handleToggleExpand,
17044
+ treeMotion,
16894
17045
  treeLines,
16895
17046
  selected,
16896
17047
  groupLeafValues,
@@ -16947,6 +17098,7 @@ function ItemList({
16947
17098
  tree,
16948
17099
  isExpanded,
16949
17100
  onToggleExpand,
17101
+ treeMotion,
16950
17102
  treeLines,
16951
17103
  selected,
16952
17104
  groupLeafValues,
@@ -16967,295 +17119,322 @@ function ItemList({
16967
17119
  "disabled:cursor-not-allowed disabled:text-[color:var(--cmp-listBoxItem-content-disabled)]"
16968
17120
  );
16969
17121
  const activeClass = "hover:bg-[var(--cmp-listBoxItem-bg-hover)] hover:text-[color:var(--cmp-listBoxItem-content-hover)] focus-visible:bg-[var(--cmp-listBoxItem-bg-hover)] focus-visible:text-[color:var(--cmp-listBoxItem-content-hover)]";
16970
- return /* @__PURE__ */ jsxs("div", { id: listboxId, role: tree ? "tree" : "listbox", "aria-multiselectable": multi || void 0, children: [
16971
- showSelectAll && (tree ? (
16972
- // 트리의 전체선택 줄은 STree 의 "전체" 줄 그대로다 (lib/tree-row).
16973
- /* @__PURE__ */ jsx(
16974
- TreeAllRow,
16975
- {
16976
- "data-select-all-row": true,
16977
- id: focusedValue === "__select_all__" ? activeOptionId : void 0,
16978
- "data-select-focused": focusedValue === "__select_all__" ? "true" : void 0,
16979
- "data-select-active": focusedValue === "__select_all__" ? "true" : void 0,
16980
- tabIndex: -1,
16981
- label: selectAllLabel,
16982
- selected: selectAllSelected,
16983
- active: focusedValue === "__select_all__",
16984
- className: "select-none",
16985
- onMouseEnter: () => onHoverValue("__select_all__"),
16986
- onMouseLeave: () => onHoverValue(null),
16987
- onToggle: onSelectAll
16988
- }
16989
- )
16990
- ) : /* @__PURE__ */ jsxs(
16991
- "button",
17122
+ const wrapTreeMotion = (nodes) => {
17123
+ if (!tree || treeMotion == null) return nodes;
17124
+ const start = items.findIndex((o) => o.isGroup && o.value === treeMotion.group);
17125
+ if (start < 0) return nodes;
17126
+ let end = start + 1;
17127
+ while (end < items.length && items[end].depth > items[start].depth) end++;
17128
+ if (end === start + 1) return nodes;
17129
+ return [
17130
+ ...nodes.slice(0, start + 1),
17131
+ /* @__PURE__ */ jsx(TreeRowsMotion, { opening: treeMotion.opening, children: nodes.slice(start + 1, end) }, `__motion_${treeMotion.group}__`),
17132
+ ...nodes.slice(end)
17133
+ ];
17134
+ };
17135
+ const selectAllRow = showSelectAll && (tree ? (
17136
+ // 트리의 전체선택 줄은 STree 의 "전체" 줄 그대로다 (lib/tree-row).
17137
+ /* @__PURE__ */ jsx(
17138
+ TreeAllRow,
16992
17139
  {
16993
- type: "button",
16994
17140
  "data-select-all-row": true,
16995
17141
  id: focusedValue === "__select_all__" ? activeOptionId : void 0,
16996
17142
  "data-select-focused": focusedValue === "__select_all__" ? "true" : void 0,
16997
17143
  "data-select-active": focusedValue === "__select_all__" ? "true" : void 0,
16998
- className: cn(
16999
- itemBase,
17000
- focusedValue === "__select_all__" && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17001
- ),
17002
- style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: LIST_BOX_ITEM_PADDING_LEFT[1] },
17144
+ tabIndex: -1,
17145
+ label: selectAllLabel,
17146
+ selected: selectAllSelected,
17147
+ active: focusedValue === "__select_all__",
17148
+ className: "select-none",
17003
17149
  onMouseEnter: () => onHoverValue("__select_all__"),
17004
17150
  onMouseLeave: () => onHoverValue(null),
17005
- onClick: onSelectAll,
17006
- children: [
17007
- /* @__PURE__ */ jsx(CheckboxBox, { state: selectAllSelected, focused: focusedValue === "__select_all__" }),
17008
- /* @__PURE__ */ jsx("span", { className: "flex-1 truncate", children: selectAllLabel })
17009
- ]
17151
+ onToggle: onSelectAll
17010
17152
  }
17011
- )),
17012
- topSpacer > 0 && /* @__PURE__ */ jsx("div", { "aria-hidden": true, style: { height: topSpacer } }),
17013
- items.map((o, windowIndex) => {
17014
- const idx = windowIndex + indexOffset;
17015
- const padLeft = LIST_BOX_ITEM_PADDING_LEFT[o.depth === 1 ? 1 : o.depth === 2 ? 2 : 3];
17016
- const rowKey = `${o.value}-${idx}`;
17017
- if (tree) {
17018
- const togglesLeaves = multi && o.isGroup;
17019
- const leafValues = togglesLeaves ? groupLeafValues.get(idx) ?? [] : [];
17020
- const total = leafValues.length;
17021
- const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17022
- const isSelected2 = selectedSet.has(o.value);
17023
- const checkState = togglesLeaves ? total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null : isSelected2;
17024
- const rowDisabled = o.disabled || togglesLeaves && total === 0;
17025
- const focusKey = togglesLeaves ? `__group_${idx}__` : o.value;
17026
- const focused = focusedValue === focusKey;
17027
- const expanded = o.isGroup && isExpanded(o.value);
17028
- const line = treeLines[idx];
17029
- const toggle = () => {
17030
- if (rowDisabled) return;
17031
- if (togglesLeaves) onToggleGroup(leafValues);
17032
- else onSelect(o.value, idx, checkboxShiftRef.current);
17033
- };
17034
- return (
17035
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- 키보드는 트리거(또는 검색 입력)가 aria-activedescendant 로 맡는다. 줄로 포커스를 옮기지 않는다
17036
- /* @__PURE__ */ jsxs(
17037
- "div",
17038
- {
17039
- tabIndex: -1,
17040
- "data-select-row": true,
17041
- "data-index": idx,
17042
- id: focused ? activeOptionId : void 0,
17043
- role: "treeitem",
17044
- "aria-level": o.depth,
17045
- "aria-posinset": line?.posInSet,
17046
- "aria-setsize": line?.setSize,
17047
- "aria-expanded": o.isGroup ? expanded : void 0,
17048
- "aria-selected": checkState === true,
17049
- "aria-disabled": rowDisabled || void 0,
17050
- "data-select-focused": focused ? "true" : void 0,
17051
- "data-select-active": focused ? "true" : void 0,
17052
- className: cn(
17053
- TREE_ROW_CLASS,
17054
- TREE_LABEL_TYPOGRAPHY.sm.rowHeight,
17055
- TREE_ROW_BORDER_CLASS,
17056
- "select-none",
17057
- o.isGroup && "cursor-pointer",
17058
- focused && TREE_ROW_ACTIVE_CLASS
17059
- ),
17060
- onMouseEnter: () => onHoverValue(focusKey),
17061
- onMouseLeave: () => onHoverValue(null),
17062
- onClick: (event) => {
17063
- if (isRowControlClick(event)) return;
17064
- if (isRowSelectClick(event)) {
17065
- if (rowDisabled) return;
17066
- if (togglesLeaves) onToggleGroup(leafValues);
17067
- else onSelect(o.value, idx, event.shiftKey);
17068
- return;
17069
- }
17070
- if (o.isGroup) onToggleExpand(o.value);
17071
- },
17072
- children: [
17073
- /* @__PURE__ */ jsx(
17074
- TreeConnector,
17075
- {
17076
- guideline: true,
17077
- size: "sm",
17078
- depth: o.depth,
17079
- hasChildren: o.isGroup,
17080
- isLast: line?.isLast ?? true,
17081
- ancestorLast: line?.ancestorLast ?? []
17082
- }
17083
- ),
17084
- /* @__PURE__ */ jsxs("div", { className: TREE_CONTENT_CLASS, children: [
17085
- o.isGroup ? (
17086
- // 비활성 그룹도 접고 편다 — 그룹이 비활성이어도 아래에 고를 수 있는 자식이 있을 수 있다.
17087
- /* @__PURE__ */ jsx(TreeCaret, { expanded, onToggle: () => onToggleExpand(o.value) })
17088
- ) : /* @__PURE__ */ jsx(TreeCaretSpacer, {}),
17089
- /* @__PURE__ */ jsx(
17090
- TreeSelectZone,
17091
- {
17092
- selectable: true,
17093
- disabled: rowDisabled,
17094
- control: multi && /* @__PURE__ */ jsx(
17095
- "span",
17096
- {
17097
- "data-tree-control": true,
17098
- className: "flex shrink-0",
17099
- onPointerDownCapture: (event) => {
17100
- checkboxShiftRef.current = event.shiftKey;
17101
- },
17102
- children: /* @__PURE__ */ jsx(
17103
- SCheckbox,
17104
- {
17105
- value: checkState,
17106
- disabled: rowDisabled,
17107
- onValueChange: toggle
17108
- }
17109
- )
17110
- }
17111
- ),
17112
- children: /* @__PURE__ */ jsx(
17113
- OptionLabel,
17114
- {
17115
- label: o.label,
17116
- className: treeLabelClass("sm", {
17117
- // 고른 줄은 굵게 — multi_tree 는 일부만 고른 그룹도 굵다 (STree 와 같다)
17118
- emphasized: checkState !== false,
17119
- disabled: rowDisabled,
17120
- selectable: true
17121
- })
17122
- }
17123
- )
17124
- }
17125
- )
17126
- ] })
17127
- ]
17153
+ )
17154
+ ) : /* @__PURE__ */ jsxs(
17155
+ "button",
17156
+ {
17157
+ type: "button",
17158
+ "data-select-all-row": true,
17159
+ id: focusedValue === "__select_all__" ? activeOptionId : void 0,
17160
+ "data-select-focused": focusedValue === "__select_all__" ? "true" : void 0,
17161
+ "data-select-active": focusedValue === "__select_all__" ? "true" : void 0,
17162
+ className: cn(
17163
+ itemBase,
17164
+ focusedValue === "__select_all__" && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17165
+ ),
17166
+ style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: LIST_BOX_ITEM_PADDING_LEFT[1] },
17167
+ onMouseEnter: () => onHoverValue("__select_all__"),
17168
+ onMouseLeave: () => onHoverValue(null),
17169
+ onClick: onSelectAll,
17170
+ children: [
17171
+ /* @__PURE__ */ jsx(CheckboxBox, { state: selectAllSelected, focused: focusedValue === "__select_all__" }),
17172
+ /* @__PURE__ */ jsx("span", { className: "flex-1 truncate", children: selectAllLabel })
17173
+ ]
17174
+ }
17175
+ ));
17176
+ const rowNodes = items.map((o, windowIndex) => {
17177
+ const idx = windowIndex + indexOffset;
17178
+ const padLeft = LIST_BOX_ITEM_PADDING_LEFT[o.depth === 1 ? 1 : o.depth === 2 ? 2 : 3];
17179
+ const rowKey = `${o.value}-${idx}`;
17180
+ if (tree) {
17181
+ const togglesLeaves = multi && o.isGroup;
17182
+ const leafValues = togglesLeaves ? groupLeafValues.get(idx) ?? [] : [];
17183
+ const total = leafValues.length;
17184
+ const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17185
+ const isSelected2 = selectedSet.has(o.value);
17186
+ const checkState = togglesLeaves ? total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null : isSelected2;
17187
+ const rowDisabled = o.disabled || togglesLeaves && total === 0;
17188
+ const focusKey = togglesLeaves ? `__group_${idx}__` : o.value;
17189
+ const focused = focusedValue === focusKey;
17190
+ const expanded = o.isGroup && isExpanded(o.value);
17191
+ const line = treeLines[idx];
17192
+ const toggle = () => {
17193
+ if (rowDisabled) return;
17194
+ if (togglesLeaves) onToggleGroup(leafValues);
17195
+ else onSelect(o.value, idx, checkboxShiftRef.current);
17196
+ };
17197
+ return (
17198
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- 키보드는 트리거(또는 검색 입력)가 aria-activedescendant 로 맡는다. 줄로 포커스를 옮기지 않는다
17199
+ /* @__PURE__ */ jsxs(
17200
+ "div",
17201
+ {
17202
+ tabIndex: -1,
17203
+ "data-select-row": true,
17204
+ "data-index": idx,
17205
+ id: focused ? activeOptionId : void 0,
17206
+ role: "treeitem",
17207
+ "aria-level": o.depth,
17208
+ "aria-posinset": line?.posInSet,
17209
+ "aria-setsize": line?.setSize,
17210
+ "aria-expanded": o.isGroup ? expanded : void 0,
17211
+ "aria-selected": checkState === true,
17212
+ "aria-disabled": rowDisabled || void 0,
17213
+ "data-select-focused": focused ? "true" : void 0,
17214
+ "data-select-active": focused ? "true" : void 0,
17215
+ className: cn(
17216
+ TREE_ROW_CLASS,
17217
+ TREE_LABEL_TYPOGRAPHY.sm.rowHeight,
17218
+ TREE_ROW_BORDER_CLASS,
17219
+ "select-none",
17220
+ o.isGroup && "cursor-pointer",
17221
+ focused && TREE_ROW_ACTIVE_CLASS
17222
+ ),
17223
+ onMouseEnter: () => onHoverValue(focusKey),
17224
+ onMouseLeave: () => onHoverValue(null),
17225
+ onClick: (event) => {
17226
+ if (isRowControlClick(event)) return;
17227
+ if (isRowSelectClick(event)) {
17228
+ if (rowDisabled) return;
17229
+ if (togglesLeaves) onToggleGroup(leafValues);
17230
+ else onSelect(o.value, idx, event.shiftKey);
17231
+ return;
17232
+ }
17233
+ if (o.isGroup) onToggleExpand(o.value);
17128
17234
  },
17129
- rowKey
17130
- )
17131
- );
17132
- }
17133
- if (o.isGroup) {
17134
- const isDepth1 = o.depth === 1;
17135
- const isFirstRow = idx === 0 && !showSelectAll;
17136
- const groupTone = cn(
17137
- isDepth1 ? "bg-[var(--cmp-listBoxItem-depth1-bg-default)] font-bold" : "bg-[var(--cmp-listBoxItem-depth2-middle-bg)] font-medium",
17138
- isDepth1 && !isFirstRow && "border-t border-solid border-[color:var(--cmp-listBoxItem-depth1-border-color)]"
17139
- );
17140
- if (multi) {
17141
- const leafValues = groupLeafValues.get(idx) ?? [];
17142
- const total = leafValues.length;
17143
- const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17144
- const groupState = total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null;
17145
- const groupDisabled = o.disabled || total === 0;
17146
- const groupFocused = focusedValue === `__group_${idx}__`;
17147
- return /* @__PURE__ */ jsxs(
17148
- "button",
17149
- {
17150
- type: "button",
17151
- "data-select-row": true,
17152
- "data-index": idx,
17153
- id: groupFocused ? activeOptionId : void 0,
17154
- role: "option",
17155
- "aria-selected": groupState === true,
17156
- disabled: groupDisabled,
17157
- "data-select-focused": groupFocused ? "true" : void 0,
17158
- "data-select-active": groupFocused ? "true" : void 0,
17159
- className: cn(
17160
- "flex w-full select-none items-center gap-(--cmp-listBoxItem-gap) border-none py-(--cmp-listBoxItem-paddingY) pr-(--cmp-listBoxItem-paddingRight) text-left typo-field-sm-default text-[color:var(--cmp-listBoxItem-content-default)]",
17161
- groupTone,
17162
- groupDisabled ? "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]" : "cursor-pointer",
17163
- groupDisabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]",
17164
- // 포커스 하이라이트는 depth 배경보다 뒤에 둬 tailwind-merge 로 이기게 한다.
17165
- groupFocused && !groupDisabled && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17235
+ children: [
17236
+ /* @__PURE__ */ jsx(
17237
+ TreeConnector,
17238
+ {
17239
+ guideline: true,
17240
+ size: "sm",
17241
+ depth: o.depth,
17242
+ hasChildren: o.isGroup,
17243
+ isLast: line?.isLast ?? true,
17244
+ ancestorLast: line?.ancestorLast ?? []
17245
+ }
17166
17246
  ),
17167
- style: { paddingLeft: padLeft },
17168
- onMouseEnter: () => onHoverValue(`__group_${idx}__`),
17169
- onMouseLeave: () => onHoverValue(null),
17170
- onClick: () => onToggleGroup(leafValues),
17171
- children: [
17172
- /* @__PURE__ */ jsx(CheckboxBox, { state: groupState, disabled: groupDisabled, focused: groupFocused }),
17173
- /* @__PURE__ */ jsx(OptionLabel, { label: o.label, className: "min-w-0 truncate" }),
17174
- total > 0 && /* @__PURE__ */ jsxs(
17175
- "span",
17247
+ /* @__PURE__ */ jsxs("div", { className: TREE_CONTENT_CLASS, children: [
17248
+ o.isGroup ? (
17249
+ // 비활성 그룹도 접고 편다 — 그룹이 비활성이어도 아래에 고를 수 있는 자식이 있을 수 있다.
17250
+ /* @__PURE__ */ jsx(TreeCaret, { expanded, onToggle: () => onToggleExpand(o.value) })
17251
+ ) : /* @__PURE__ */ jsx(TreeCaretSpacer, {}),
17252
+ /* @__PURE__ */ jsx(
17253
+ TreeSelectZone,
17176
17254
  {
17177
- className: cn(
17178
- "shrink-0 font-medium",
17179
- groupFocused ? "text-white" : "text-[color:var(--cmp-listBoxItem-content-disabled)]"
17255
+ selectable: true,
17256
+ disabled: rowDisabled,
17257
+ control: multi && /* @__PURE__ */ jsx(
17258
+ "span",
17259
+ {
17260
+ "data-tree-control": true,
17261
+ className: "flex shrink-0",
17262
+ onPointerDownCapture: (event) => {
17263
+ checkboxShiftRef.current = event.shiftKey;
17264
+ },
17265
+ children: /* @__PURE__ */ jsx(SCheckbox, { value: checkState, disabled: rowDisabled, onValueChange: toggle })
17266
+ }
17180
17267
  ),
17181
- children: [
17182
- "(",
17183
- selectedCount,
17184
- "/",
17185
- total,
17186
- ")"
17187
- ]
17268
+ children: /* @__PURE__ */ jsx(
17269
+ OptionLabel,
17270
+ {
17271
+ label: o.label,
17272
+ className: treeLabelClass("sm", {
17273
+ // 고른 줄은 굵게 — multi_tree 는 일부만 고른 그룹도 굵다 (STree 와 같다)
17274
+ emphasized: checkState !== false,
17275
+ disabled: rowDisabled,
17276
+ selectable: true
17277
+ })
17278
+ }
17279
+ )
17188
17280
  }
17189
17281
  )
17190
- ]
17191
- },
17192
- rowKey
17193
- );
17194
- }
17195
- return /* @__PURE__ */ jsx(
17196
- "div",
17282
+ ] })
17283
+ ]
17284
+ },
17285
+ rowKey
17286
+ )
17287
+ );
17288
+ }
17289
+ if (o.isGroup) {
17290
+ const isDepth1 = o.depth === 1;
17291
+ const isFirstRow = idx === 0 && !showSelectAll;
17292
+ const groupTone = cn(
17293
+ isDepth1 ? "bg-[var(--cmp-listBoxItem-depth1-bg-default)] font-bold" : "bg-[var(--cmp-listBoxItem-depth2-middle-bg)] font-medium",
17294
+ isDepth1 && !isFirstRow && "border-t border-solid border-[color:var(--cmp-listBoxItem-depth1-border-color)]"
17295
+ );
17296
+ if (multi) {
17297
+ const leafValues = groupLeafValues.get(idx) ?? [];
17298
+ const total = leafValues.length;
17299
+ const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17300
+ const groupState = total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null;
17301
+ const groupDisabled = o.disabled || total === 0;
17302
+ const groupFocused = focusedValue === `__group_${idx}__`;
17303
+ return /* @__PURE__ */ jsxs(
17304
+ "button",
17197
17305
  {
17306
+ type: "button",
17198
17307
  "data-select-row": true,
17199
17308
  "data-index": idx,
17309
+ id: groupFocused ? activeOptionId : void 0,
17310
+ role: "option",
17311
+ "aria-selected": groupState === true,
17312
+ disabled: groupDisabled,
17313
+ "data-select-focused": groupFocused ? "true" : void 0,
17314
+ "data-select-active": groupFocused ? "true" : void 0,
17200
17315
  className: cn(
17201
- "select-none py-(--cmp-listBoxItem-paddingY) pr-(--cmp-listBoxItem-paddingRight) typo-field-sm-default text-[color:var(--cmp-listBoxItem-content-default)]",
17316
+ "flex w-full select-none items-center gap-(--cmp-listBoxItem-gap) border-none py-(--cmp-listBoxItem-paddingY) pr-(--cmp-listBoxItem-paddingRight) text-left typo-field-sm-default text-[color:var(--cmp-listBoxItem-content-default)]",
17202
17317
  groupTone,
17203
- // 하위가 전부 비활성이면(select.config 에서 bubble-up) 어미 그룹도 비활성 처리:
17204
- // 텍스트는 회색(#888), depth1 그룹은 파란 배경 대신 회색(#EEE)으로 (depth2 는 #F6F6F6 유지)
17205
- o.disabled && "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]",
17206
- o.disabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]"
17318
+ groupDisabled ? "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]" : "cursor-pointer",
17319
+ groupDisabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]",
17320
+ // 포커스 하이라이트는 depth 배경보다 뒤에 둬 tailwind-merge 로 이기게 한다.
17321
+ groupFocused && !groupDisabled && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17207
17322
  ),
17208
17323
  style: { paddingLeft: padLeft },
17209
- children: o.label
17324
+ onMouseEnter: () => onHoverValue(`__group_${idx}__`),
17325
+ onMouseLeave: () => onHoverValue(null),
17326
+ onClick: () => onToggleGroup(leafValues),
17327
+ children: [
17328
+ /* @__PURE__ */ jsx(CheckboxBox, { state: groupState, disabled: groupDisabled, focused: groupFocused }),
17329
+ /* @__PURE__ */ jsx(OptionLabel, { label: o.label, className: "min-w-0 truncate" }),
17330
+ total > 0 && /* @__PURE__ */ jsxs(
17331
+ "span",
17332
+ {
17333
+ className: cn(
17334
+ "shrink-0 font-medium",
17335
+ groupFocused ? "text-white" : "text-[color:var(--cmp-listBoxItem-content-disabled)]"
17336
+ ),
17337
+ children: [
17338
+ "(",
17339
+ selectedCount,
17340
+ "/",
17341
+ total,
17342
+ ")"
17343
+ ]
17344
+ }
17345
+ )
17346
+ ]
17210
17347
  },
17211
17348
  rowKey
17212
17349
  );
17213
17350
  }
17214
- const isSelected = selected.includes(o.value);
17215
- return /* @__PURE__ */ jsxs(
17216
- "button",
17351
+ return /* @__PURE__ */ jsx(
17352
+ "div",
17217
17353
  {
17218
- type: "button",
17219
17354
  "data-select-row": true,
17220
17355
  "data-index": idx,
17221
- id: focusedValue === o.value ? activeOptionId : void 0,
17222
- disabled: o.disabled,
17223
- role: "option",
17224
- "aria-selected": isSelected,
17225
- "aria-setsize": setSize,
17226
- "aria-posinset": setSize != null ? idx + 1 : void 0,
17227
- "data-select-focused": focusedValue === o.value ? "true" : void 0,
17228
- "data-select-active": focusedValue === o.value ? "true" : void 0,
17229
17356
  className: cn(
17230
- itemBase,
17231
- isSelected && !multi && "font-bold text-[color:var(--cmp-listBoxItem-content-selected)]",
17232
- focusedValue === o.value && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]",
17233
- // 비활성 항목은 hover/focus 스타일 대상에서 제외한다.
17234
- // (:hover 는 disabled 버튼에도 매칭되므로 클래스 자체를 붙이지 않아야 한다)
17235
- !o.disabled && activeClass
17357
+ "select-none py-(--cmp-listBoxItem-paddingY) pr-(--cmp-listBoxItem-paddingRight) typo-field-sm-default text-[color:var(--cmp-listBoxItem-content-default)]",
17358
+ groupTone,
17359
+ // 하위가 전부 비활성이면(select.config 에서 bubble-up) 어미 그룹도 비활성 처리:
17360
+ // 텍스트는 회색(#888), depth1 그룹은 파란 배경 대신 회색(#EEE)으로 (depth2 는 #F6F6F6 유지)
17361
+ o.disabled && "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]",
17362
+ o.disabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]"
17236
17363
  ),
17237
- style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: padLeft },
17238
- onMouseEnter: () => onHoverValue(o.value),
17239
- onMouseLeave: () => onHoverValue(null),
17240
- onClick: (event) => onSelect(o.value, idx, event.shiftKey),
17241
- children: [
17242
- multi && /* @__PURE__ */ jsx(
17243
- CheckboxBox,
17244
- {
17245
- state: isSelected,
17246
- disabled: o.disabled,
17247
- focused: focusedValue === o.value
17248
- }
17249
- ),
17250
- /* @__PURE__ */ jsx(OptionLabel, { label: o.label, className: "flex-1 truncate" })
17251
- ]
17364
+ style: { paddingLeft: padLeft },
17365
+ children: o.label
17252
17366
  },
17253
17367
  rowKey
17254
17368
  );
17255
- }),
17369
+ }
17370
+ const isSelected = selected.includes(o.value);
17371
+ return /* @__PURE__ */ jsxs(
17372
+ "button",
17373
+ {
17374
+ type: "button",
17375
+ "data-select-row": true,
17376
+ "data-index": idx,
17377
+ id: focusedValue === o.value ? activeOptionId : void 0,
17378
+ disabled: o.disabled,
17379
+ role: "option",
17380
+ "aria-selected": isSelected,
17381
+ "aria-setsize": setSize,
17382
+ "aria-posinset": setSize != null ? idx + 1 : void 0,
17383
+ "data-select-focused": focusedValue === o.value ? "true" : void 0,
17384
+ "data-select-active": focusedValue === o.value ? "true" : void 0,
17385
+ className: cn(
17386
+ itemBase,
17387
+ isSelected && !multi && "font-bold text-[color:var(--cmp-listBoxItem-content-selected)]",
17388
+ focusedValue === o.value && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]",
17389
+ // 비활성 항목은 hover/focus 스타일 대상에서 제외한다.
17390
+ // (:hover 는 disabled 버튼에도 매칭되므로 클래스 자체를 붙이지 않아야 한다)
17391
+ !o.disabled && activeClass
17392
+ ),
17393
+ style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: padLeft },
17394
+ onMouseEnter: () => onHoverValue(o.value),
17395
+ onMouseLeave: () => onHoverValue(null),
17396
+ onClick: (event) => onSelect(o.value, idx, event.shiftKey),
17397
+ children: [
17398
+ multi && /* @__PURE__ */ jsx(
17399
+ CheckboxBox,
17400
+ {
17401
+ state: isSelected,
17402
+ disabled: o.disabled,
17403
+ focused: focusedValue === o.value
17404
+ }
17405
+ ),
17406
+ /* @__PURE__ */ jsx(OptionLabel, { label: o.label, className: "flex-1 truncate" })
17407
+ ]
17408
+ },
17409
+ rowKey
17410
+ );
17411
+ });
17412
+ return /* @__PURE__ */ jsxs("div", { id: listboxId, role: tree ? "tree" : "listbox", "aria-multiselectable": multi || void 0, children: [
17413
+ selectAllRow,
17414
+ topSpacer > 0 && /* @__PURE__ */ jsx("div", { "aria-hidden": true, style: { height: topSpacer } }),
17415
+ wrapTreeMotion(rowNodes),
17256
17416
  bottomSpacer > 0 && /* @__PURE__ */ jsx("div", { "aria-hidden": true, style: { height: bottomSpacer } })
17257
17417
  ] });
17258
17418
  }
17419
+ function TreeRowsMotion({ opening, children }) {
17420
+ const ref = useRef(null);
17421
+ const animationRef = useRef(null);
17422
+ const mountedRef = useRef(false);
17423
+ useLayoutEffect(() => {
17424
+ const el = ref.current;
17425
+ if (el == null || typeof el.animate !== "function") return;
17426
+ const from = !mountedRef.current && opening ? 0 : el.getBoundingClientRect().height;
17427
+ mountedRef.current = true;
17428
+ animationRef.current?.cancel();
17429
+ animationRef.current = el.animate(
17430
+ [{ height: `${from}px` }, { height: opening ? `${el.scrollHeight}px` : "0px" }],
17431
+ // 접힌 뒤 줄이 목록에서 빠질 때까지 0 으로 붙잡아 둔다. 편 뒤에는 제 높이(auto)로 돌아간다
17432
+ { ...TREE_BRANCH_MOTION, fill: opening ? "none" : "forwards" }
17433
+ );
17434
+ }, [opening]);
17435
+ useEffect(() => () => animationRef.current?.cancel(), []);
17436
+ return /* @__PURE__ */ jsx("div", { ref, className: "overflow-hidden", "aria-hidden": opening ? void 0 : true, children });
17437
+ }
17259
17438
  function OptionLabel({ label, className }) {
17260
17439
  return isHtmlLabel(label) ? /* @__PURE__ */ jsx("span", { className, dangerouslySetInnerHTML: { __html: sanitizeInlineHtml(label) } }) : /* @__PURE__ */ jsx("span", { className, children: label });
17261
17440
  }
@@ -17304,6 +17483,7 @@ function CheckboxBox({
17304
17483
  }
17305
17484
  );
17306
17485
  }
17486
+ var NO_KEYS = /* @__PURE__ */ new Set();
17307
17487
  var DEFAULT_ROW_HEIGHT = 44;
17308
17488
  var DENSE_ROW_HEIGHT = 32;
17309
17489
  var SELECTABLE_COLUMN_WIDTH = 48;
@@ -17334,6 +17514,11 @@ var CONTEXT_MENU_ITEM_CLASS = cn(
17334
17514
  "data-[highlighted]:bg-[var(--sys-color-bg-accent)] data-[highlighted]:text-[color:var(--sys-color-fg-inverse)]"
17335
17515
  );
17336
17516
  var ROW_INTERACTIVE_SELECTOR = 'button, a, input, select, textarea, label, [role="button"], [role="checkbox"], [contenteditable="true"]';
17517
+ function rowBlockHeight(tr) {
17518
+ const next = tr.nextElementSibling;
17519
+ const expansion = next instanceof HTMLElement && next.dataset.expansionRow != null ? next.offsetHeight : 0;
17520
+ return tr.offsetHeight + expansion;
17521
+ }
17337
17522
  var colWidthFromDef = (c) => {
17338
17523
  if (c.autoWidth) return 0;
17339
17524
  if (c.width == null) return DEFAULT_COLUMN_WIDTH;
@@ -17346,6 +17531,8 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17346
17531
  rows = [],
17347
17532
  rowKey = "id",
17348
17533
  selectable = false,
17534
+ rowSelectLabel,
17535
+ selectAllLabel = "\uC804\uCCB4 \uC120\uD0DD",
17349
17536
  dragSelectable = false,
17350
17537
  contextMenuItems,
17351
17538
  onContextMenuItemClick,
@@ -17384,6 +17571,11 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17384
17571
  tableId,
17385
17572
  onRowClick,
17386
17573
  onRowDoubleClick,
17574
+ renderExpansion,
17575
+ expansionColumn,
17576
+ expandedKeys,
17577
+ defaultExpandedKeys,
17578
+ onExpandedChange,
17387
17579
  className,
17388
17580
  style
17389
17581
  }, ref) {
@@ -17398,6 +17590,44 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17398
17590
  onDenseChange?.(next);
17399
17591
  };
17400
17592
  const visibleCols = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
17593
+ const expansionOn = renderExpansion != null;
17594
+ const [innerExpandedKeys, setInnerExpandedKeys] = useState(
17595
+ () => defaultExpandedKeys ?? []
17596
+ );
17597
+ const currentExpandedKeys = expandedKeys ?? innerExpandedKeys;
17598
+ const expandedSet = useMemo(() => new Set(currentExpandedKeys), [currentExpandedKeys]);
17599
+ const isRowExpanded = (row) => expansionOn && expandedSet.has(row[rowKey]);
17600
+ const [closingKeys, setClosingKeys] = useState(NO_KEYS);
17601
+ const [prevExpandedSet, setPrevExpandedSet] = useState(expandedSet);
17602
+ if (prevExpandedSet !== expandedSet) {
17603
+ setPrevExpandedSet(expandedSet);
17604
+ let nextClosing = NO_KEYS;
17605
+ if (expansionOn && motionAllowed()) {
17606
+ const collapsed = [...prevExpandedSet].filter((key) => !expandedSet.has(key));
17607
+ const opened = [...expandedSet].filter((key) => !prevExpandedSet.has(key));
17608
+ if (collapsed.length > 0 && opened.length === 0) nextClosing = new Set(collapsed);
17609
+ }
17610
+ if (nextClosing !== closingKeys) setClosingKeys(nextClosing);
17611
+ }
17612
+ const shownExpansionSet = useMemo(
17613
+ () => closingKeys.size === 0 ? expandedSet : /* @__PURE__ */ new Set([...expandedSet, ...closingKeys]),
17614
+ [expandedSet, closingKeys]
17615
+ );
17616
+ const toggleExpanded = (row) => {
17617
+ const key = row[rowKey];
17618
+ const next = expandedSet.has(key) ? currentExpandedKeys.filter((k) => k !== key) : [...currentExpandedKeys, key];
17619
+ if (expandedKeys === void 0) setInnerExpandedKeys(next);
17620
+ onExpandedChange?.(next, row);
17621
+ };
17622
+ const expansionColIndex = !expansionOn ? -1 : expansionColumn == null ? 0 : visibleCols.findIndex((c) => c.name === expansionColumn);
17623
+ if (expansionColIndex >= 0) {
17624
+ const col = visibleCols[expansionColIndex];
17625
+ if (col?.renderCell) {
17626
+ throw new Error(
17627
+ `[STable] \uD3BC\uCE68 \uCEEC\uB7FC(${col.name})\uC5D0\uB294 renderCell \uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. render \uB97C \uC4F0\uC138\uC694.`
17628
+ );
17629
+ }
17630
+ }
17401
17631
  const headerGroupInfo = useMemo(() => {
17402
17632
  if (!headerGroups || headerGroups.length === 0) return null;
17403
17633
  const indexByName = new Map(visibleCols.map((col, index) => [col.name, index]));
@@ -17502,6 +17732,7 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17502
17732
  metricsRef.current ?? (metricsRef.current = createRowMetrics(0, effectiveRowHeight));
17503
17733
  const metrics = metricsRef.current;
17504
17734
  const measuredKeyRef = useRef("");
17735
+ const measuredExpansionRef = useRef(shownExpansionSet);
17505
17736
  const pendingRowRef = useRef(null);
17506
17737
  const pinnedTopRef = useRef(-1);
17507
17738
  const lastEstimateRef = useRef(effectiveRowHeight);
@@ -17515,6 +17746,7 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17515
17746
  }
17516
17747
  const [scrolledLeft, setScrolledLeft] = useState(false);
17517
17748
  const [scrolledRight, setScrolledRight] = useState(false);
17749
+ const [viewportWidth, setViewportWidth] = useState(0);
17518
17750
  const setRowCount = useCallback((count) => {
17519
17751
  const safe = Math.max(0, Math.floor(Number.isFinite(count) ? count : 0));
17520
17752
  lastReachEndRef.current = -1;
@@ -17540,6 +17772,7 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17540
17772
  setHeaderHeight(headerH);
17541
17773
  setScrolledLeft(el.scrollLeft > 0);
17542
17774
  setScrolledRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
17775
+ setViewportWidth(el.clientWidth);
17543
17776
  if (virtualScroll) {
17544
17777
  const { start, end } = calcRange(el.scrollTop, el.clientHeight - headerH);
17545
17778
  setVsRange((prev) => prev.start === start && prev.end === end ? prev : { start, end });
@@ -17557,6 +17790,13 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17557
17790
  useEffect(() => {
17558
17791
  handleScroll();
17559
17792
  }, [handleScroll, rowCount, columnWidths, loading2, height]);
17793
+ useEffect(() => {
17794
+ const el = scrollRef.current;
17795
+ if (!expansionOn || !el || typeof ResizeObserver === "undefined") return;
17796
+ const observer = new ResizeObserver(() => handleScroll());
17797
+ observer.observe(el);
17798
+ return () => observer.disconnect();
17799
+ }, [expansionOn, handleScroll]);
17560
17800
  const handleScrollRef = useRef(handleScroll);
17561
17801
  handleScrollRef.current = handleScroll;
17562
17802
  useIsomorphicLayoutEffect(() => {
@@ -17566,9 +17806,10 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17566
17806
  if (!el || m == null) return;
17567
17807
  const pending2 = pendingRowRef.current;
17568
17808
  const key = `${vsRange.start}:${vsRange.end}:${rowCount}`;
17569
- const alreadyMeasured = measuredKeyRef.current === key;
17809
+ const alreadyMeasured = measuredKeyRef.current === key && measuredExpansionRef.current === shownExpansionSet;
17570
17810
  if (alreadyMeasured && pending2 == null) return;
17571
17811
  measuredKeyRef.current = key;
17812
+ measuredExpansionRef.current = shownExpansionSet;
17572
17813
  const anchor = vsRange.start;
17573
17814
  const anchorBefore = offsetOf(m, anchor);
17574
17815
  let changed = false;
@@ -17576,7 +17817,7 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17576
17817
  for (const tr of el.querySelectorAll("tr[data-row-index]")) {
17577
17818
  const index = Number(tr.dataset.rowIndex);
17578
17819
  if (!Number.isInteger(index)) continue;
17579
- const h = tr.offsetHeight;
17820
+ const h = rowBlockHeight(tr);
17580
17821
  if (h <= 0) continue;
17581
17822
  if (setRowSize(m, index, h)) changed = true;
17582
17823
  }
@@ -17598,6 +17839,54 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17598
17839
  if (delta !== 0 && el.scrollTop > 0) el.scrollTop += delta;
17599
17840
  handleScrollRef.current();
17600
17841
  });
17842
+ const expansionMotionRef = useRef(null);
17843
+ const prevExpansionRef = useRef(null);
17844
+ useIsomorphicLayoutEffect(() => {
17845
+ if (!expansionOn) return;
17846
+ const prev = prevExpansionRef.current;
17847
+ prevExpansionRef.current = { rows, keys: expandedSet };
17848
+ const running = expansionMotionRef.current;
17849
+ if (running?.done) {
17850
+ running.cancel();
17851
+ expansionMotionRef.current = null;
17852
+ }
17853
+ if (!prev || prev.keys === expandedSet) return;
17854
+ const added = new Set([...expandedSet].filter((key) => !prev.keys.has(key)));
17855
+ const removed = new Set([...prev.keys].filter((key) => !expandedSet.has(key)));
17856
+ if (added.size === 0 && removed.size === 0) return;
17857
+ let from = added.size > 0 ? 0 : 1;
17858
+ if (expansionMotionRef.current) {
17859
+ const current = expansionMotionRef.current;
17860
+ const reversing = current.to === 0 && sameKeys(added, current.keys) || current.to === 1 && sameKeys(removed, current.keys);
17861
+ if (reversing) from = current.openness();
17862
+ current.cancel();
17863
+ expansionMotionRef.current = null;
17864
+ }
17865
+ const scrollEl = scrollRef.current;
17866
+ const keys = added.size > 0 ? added : removed;
17867
+ const keyNames = new Set([...keys].map(String));
17868
+ const canAnimate = scrollEl != null && prev.rows === rows && motionAllowed() && (added.size === 0 || removed.size === 0);
17869
+ const motion = canAnimate ? startRowMotion({
17870
+ scrollEl,
17871
+ isInBlock: (tr) => tr.dataset.expansionRow != null && keyNames.has(tr.dataset.expansionKey ?? ""),
17872
+ blockKeys: keys,
17873
+ from,
17874
+ to: added.size > 0 ? 1 : 0
17875
+ }) : null;
17876
+ if (motion) {
17877
+ expansionMotionRef.current = motion;
17878
+ if (motion.to === 0) {
17879
+ void motion.finished.then((completed) => {
17880
+ if (!completed || expansionMotionRef.current !== motion) return;
17881
+ motion.done = true;
17882
+ setClosingKeys(NO_KEYS);
17883
+ });
17884
+ }
17885
+ } else if (closingKeys.size > 0) {
17886
+ setClosingKeys(NO_KEYS);
17887
+ }
17888
+ }, [expandedSet, closingKeys]);
17889
+ useEffect(() => () => expansionMotionRef.current?.cancel(), []);
17601
17890
  useEffect(() => {
17602
17891
  if (!virtualScroll) return;
17603
17892
  cbRef.current.onVirtualUpdate?.({ from: vsRange.start, to: vsRange.end });
@@ -17654,7 +17943,7 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17654
17943
  if (virtualScroll && m != null) return indexAtOffset(m, el.scrollTop);
17655
17944
  const top = el.scrollTop;
17656
17945
  for (const tr of el.querySelectorAll("tr[data-row-index]")) {
17657
- if (tr.offsetTop + tr.offsetHeight > top) return Number(tr.dataset.rowIndex) || 0;
17946
+ if (tr.offsetTop + rowBlockHeight(tr) > top) return Number(tr.dataset.rowIndex) || 0;
17658
17947
  }
17659
17948
  return 0;
17660
17949
  },
@@ -17982,7 +18271,8 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
17982
18271
  {
17983
18272
  value: headerCheck,
17984
18273
  disabled: !hasSelectableRow,
17985
- onValueChange: toggleAll
18274
+ onValueChange: toggleAll,
18275
+ ariaLabel: selectAllLabel
17986
18276
  }
17987
18277
  ) })
17988
18278
  }
@@ -18028,7 +18318,8 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
18028
18318
  {
18029
18319
  value: headerCheck,
18030
18320
  disabled: !hasSelectableRow,
18031
- onValueChange: toggleAll
18321
+ onValueChange: toggleAll,
18322
+ ariaLabel: selectAllLabel
18032
18323
  }
18033
18324
  ) }),
18034
18325
  /* @__PURE__ */ jsx(
@@ -18228,112 +18519,168 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
18228
18519
  const rowLocked = (selectable || dragSelectable) && !canSelectRow(row);
18229
18520
  const rowBg = "bg-white";
18230
18521
  const rowHover = hoverable && !rowLocked;
18231
- return /* @__PURE__ */ jsxs(
18232
- "tr",
18233
- {
18234
- "data-row-index": rowIndex,
18235
- onClick: () => onRowClick?.(row),
18236
- onDoubleClick: onRowDoubleClick ? (event) => {
18237
- const target = event.target;
18238
- if (target.closest(ROW_INTERACTIVE_SELECTOR)) return;
18239
- onRowDoubleClick(row);
18240
- } : void 0,
18241
- onMouseDown: dragSelectOn ? (event) => startDragSelect(rowIndex, event) : void 0,
18242
- onMouseEnter: dragSelectOn ? () => extendDragSelect(rowIndex) : void 0,
18243
- onContextMenu: contextMenuOn ? (event) => openContextMenu(row, event) : void 0,
18244
- className: cn(
18245
- "group/table-row",
18246
- rowHover && "hover:bg-[var(--color-grey-05)]",
18247
- (onRowClick || onRowDoubleClick) && "cursor-pointer",
18248
- dragSelectOn && "select-none",
18249
- rowClassName?.(row)
18250
- ),
18251
- style: { height: virtualScroll ? effectiveRowHeight : rowH },
18252
- children: [
18253
- selectable && /* @__PURE__ */ jsx(
18254
- "td",
18255
- {
18256
- className: cn(
18257
- // 원본 .td--selected: 48px 고정폭, 항상 left sticky, 체크박스 중앙정렬
18258
- "sticky left-0 z-[2] border-b border-solid border-[color:var(--cmp-table-border-color)] align-middle",
18522
+ const expansionShown = expansionOn && shownExpansionSet.has(row[rowKey]);
18523
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
18524
+ /* @__PURE__ */ jsxs(
18525
+ "tr",
18526
+ {
18527
+ "data-row-index": rowIndex,
18528
+ onClick: onRowClick ? (event) => {
18529
+ const target = event.target;
18530
+ if (target.closest(ROW_INTERACTIVE_SELECTOR)) return;
18531
+ onRowClick(row);
18532
+ } : void 0,
18533
+ onDoubleClick: onRowDoubleClick ? (event) => {
18534
+ const target = event.target;
18535
+ if (target.closest(ROW_INTERACTIVE_SELECTOR)) return;
18536
+ onRowDoubleClick(row);
18537
+ } : void 0,
18538
+ onMouseDown: dragSelectOn ? (event) => startDragSelect(rowIndex, event) : void 0,
18539
+ onMouseEnter: dragSelectOn ? () => extendDragSelect(rowIndex) : void 0,
18540
+ onContextMenu: contextMenuOn ? (event) => openContextMenu(row, event) : void 0,
18541
+ className: cn(
18542
+ "group/table-row",
18543
+ rowHover && "hover:bg-[var(--color-grey-05)]",
18544
+ (onRowClick || onRowDoubleClick) && "cursor-pointer",
18545
+ dragSelectOn && "select-none",
18546
+ rowClassName?.(row)
18547
+ ),
18548
+ style: { height: virtualScroll ? effectiveRowHeight : rowH },
18549
+ children: [
18550
+ selectable && /* @__PURE__ */ jsx(
18551
+ "td",
18552
+ {
18553
+ className: cn(
18554
+ // 원본 .td--selected: 48px 고정폭, 항상 left sticky, 체크박스 중앙정렬
18555
+ "sticky left-0 z-[2] border-b border-solid border-[color:var(--cmp-table-border-color)] align-middle",
18556
+ rowBg,
18557
+ rowHover && "group-hover/table-row:bg-[var(--color-grey-05)]",
18558
+ stickyLeft === 0 && scrolledLeft && "shadow-[5px_0_8px_0_rgba(34,34,34,0.1)]"
18559
+ ),
18560
+ style: {
18561
+ width: SELECTABLE_COLUMN_WIDTH,
18562
+ minWidth: SELECTABLE_COLUMN_WIDTH,
18563
+ maxWidth: SELECTABLE_COLUMN_WIDTH,
18564
+ // 선택 열도 행의 일부다 — 여기만 희지 않으면 회색 줄이 끊긴다
18565
+ background: rowLocked ? LOCKED_BG : void 0
18566
+ },
18567
+ onClick: (e) => e.stopPropagation(),
18568
+ onDoubleClick: (e) => e.stopPropagation(),
18569
+ onClickCapture: (e) => {
18570
+ shiftPressedRef.current = e.shiftKey;
18571
+ if (e.shiftKey) window.getSelection()?.removeAllRanges();
18572
+ },
18573
+ children: /* @__PURE__ */ jsx("div", { className: "flex select-none items-center justify-center", children: /* @__PURE__ */ jsx(
18574
+ SCheckbox,
18575
+ {
18576
+ value: isSel || !isRowIndeterminate?.(row) ? isSel : null,
18577
+ disabled: !canSelectRow(row),
18578
+ onValueChange: () => toggleRow(row, rowOffset + ri),
18579
+ ariaLabel: rowSelectLabel?.(row) ?? "\uD589 \uC120\uD0DD"
18580
+ }
18581
+ ) })
18582
+ }
18583
+ ),
18584
+ visibleCols.map((col, i) => {
18585
+ const tdClassName = cn(
18586
+ // 원본 .td: 컬럼 폭에 맞춰 내용 말줄임(overflow hidden + ellipsis) + 행 구분선(border-b)
18587
+ // 셀 배경은 흰색으로 채워 뒤 배경이 비치지 않게 하고, hover 시 행 배경(grey_05)을 따른다.
18588
+ "overflow-hidden text-ellipsis whitespace-nowrap border-b border-solid border-[color:var(--cmp-table-border-color)] text-[12px] leading-[20px] text-[color:var(--sys-color-fg-primary)]",
18259
18589
  rowBg,
18260
18590
  rowHover && "group-hover/table-row:bg-[var(--color-grey-05)]",
18261
- stickyLeft === 0 && scrolledLeft && "shadow-[5px_0_8px_0_rgba(34,34,34,0.1)]"
18262
- ),
18263
- style: {
18264
- width: SELECTABLE_COLUMN_WIDTH,
18265
- minWidth: SELECTABLE_COLUMN_WIDTH,
18266
- maxWidth: SELECTABLE_COLUMN_WIDTH,
18267
- // 선택 열도 행의 일부다 — 여기만 희지 않으면 회색 줄이 끊긴다
18268
- background: rowLocked ? LOCKED_BG : void 0
18269
- },
18270
- onClick: (e) => e.stopPropagation(),
18271
- onDoubleClick: (e) => e.stopPropagation(),
18272
- onClickCapture: (e) => {
18273
- shiftPressedRef.current = e.shiftKey;
18274
- if (e.shiftKey) window.getSelection()?.removeAllRanges();
18275
- },
18276
- children: /* @__PURE__ */ jsx("div", { className: "flex select-none items-center justify-center", children: /* @__PURE__ */ jsx(
18277
- SCheckbox,
18591
+ typeof col.tdClass === "function" ? col.tdClass(row) : col.tdClass
18592
+ );
18593
+ const tdPadX = cellPadXOf(col);
18594
+ const dragEdges = dragSel ? [
18595
+ dragTop && `inset 0 ${DRAG_EDGE_W} 0 0 ${DRAG_EDGE_C}`,
18596
+ dragBottom && `inset 0 ${DRAG_EDGE_NEG_W} 0 0 ${DRAG_EDGE_C}`,
18597
+ i === 0 && `inset ${DRAG_EDGE_W} 0 0 0 ${DRAG_EDGE_C}`,
18598
+ i === visibleCols.length - 1 && `inset ${DRAG_EDGE_NEG_W} 0 0 0 ${DRAG_EDGE_C}`
18599
+ ].filter(Boolean) : [];
18600
+ const shadows = [...dragEdges, stickyShadowValue(i)].filter(Boolean);
18601
+ const tdStyle = {
18602
+ paddingLeft: tdPadX,
18603
+ paddingRight: tdPadX,
18604
+ paddingTop: cellPadY,
18605
+ paddingBottom: cellPadY,
18606
+ textAlign: col.align ?? "left",
18607
+ // 인라인이라 hover 배경보다 세다 — 고른 행은 hover 로 흐려지지 않는다.
18608
+ background: dragSel ? DRAG_BG : rowLocked ? LOCKED_BG : void 0,
18609
+ // 클래스의 기본 글자색을 덮는다. 셀이 자기 색을 정한 내용(SIcon 의
18610
+ // color, STag 등)까지는 닿지 않는다 — 그건 소비 앱 몫이다.
18611
+ color: rowLocked ? LOCKED_CONTENT : void 0,
18612
+ boxShadow: shadows.length > 0 ? shadows.join(", ") : void 0,
18613
+ ...cellStyle(i)
18614
+ };
18615
+ if (col.renderCell) {
18616
+ const cell = col.renderCell({
18617
+ row,
18618
+ value: cellValue(col, row),
18619
+ column: col,
18620
+ colIndex: i,
18621
+ rowIndex: ri,
18622
+ isSelected: isSel,
18623
+ className: tdClassName,
18624
+ style: tdStyle
18625
+ });
18626
+ if (cell == null || cell === false) return null;
18627
+ return /* @__PURE__ */ jsx(Fragment$1, { children: cell }, col.name);
18628
+ }
18629
+ const content = col.render ? col.render(row, cellValue(col, row)) : cellValue(col, row);
18630
+ if (i === expansionColIndex) {
18631
+ return /* @__PURE__ */ jsx(
18632
+ "td",
18633
+ {
18634
+ className: cn(tdClassName, "cursor-pointer"),
18635
+ style: tdStyle,
18636
+ onClick: (event) => {
18637
+ if (isOwnControl(event.target, event.currentTarget)) return;
18638
+ event.stopPropagation();
18639
+ toggleExpanded(row);
18640
+ },
18641
+ onDoubleClick: (event) => event.stopPropagation(),
18642
+ children: /* @__PURE__ */ jsx(ExpansionToggleCell, { expanded: isRowExpanded(row), children: content })
18643
+ },
18644
+ col.name
18645
+ );
18646
+ }
18647
+ return /* @__PURE__ */ jsx("td", { className: tdClassName, style: tdStyle, children: content }, col.name);
18648
+ })
18649
+ ]
18650
+ }
18651
+ ),
18652
+ expansionShown && // 상세 영역 — 행이 아니므로 data-row-index 를 달지 않는다 (행 수·선택·실측 대상 밖)
18653
+ /* @__PURE__ */ jsx(
18654
+ "tr",
18655
+ {
18656
+ "data-expansion-row": "",
18657
+ "data-expansion-key": String(row[rowKey]),
18658
+ className: expandedSet.has(row[rowKey]) ? void 0 : "pointer-events-none",
18659
+ children: /* @__PURE__ */ jsx(
18660
+ "td",
18661
+ {
18662
+ colSpan: totalColSpan,
18663
+ className: "whitespace-normal border-b border-solid border-[color:var(--cmp-table-border-color)] bg-white p-0 text-[12px] leading-[20px] text-[color:var(--sys-color-fg-primary)]",
18664
+ children: /* @__PURE__ */ jsx(
18665
+ "div",
18278
18666
  {
18279
- value: isSel || !isRowIndeterminate?.(row) ? isSel : null,
18280
- disabled: !canSelectRow(row),
18281
- onValueChange: () => toggleRow(row, rowOffset + ri)
18667
+ className: "sticky left-0 box-border min-w-0",
18668
+ style: {
18669
+ // 가로 스크롤이 있어도 보이는 폭에 머문다. 재기 전(0)에는 표 폭을 따른다
18670
+ width: viewportWidth > 0 ? viewportWidth : void 0,
18671
+ paddingLeft: "var(--cmp-table-body-paddingX-wide)",
18672
+ paddingRight: "var(--cmp-table-body-paddingX-wide)",
18673
+ paddingTop: cellPadY,
18674
+ paddingBottom: cellPadY
18675
+ },
18676
+ children: renderExpansion?.(row)
18282
18677
  }
18283
- ) })
18284
- }
18285
- ),
18286
- visibleCols.map((col, i) => {
18287
- const tdClassName = cn(
18288
- // 원본 .td: 컬럼 폭에 맞춰 내용 말줄임(overflow hidden + ellipsis) + 행 구분선(border-b)
18289
- // 셀 배경은 흰색으로 채워 뒤 배경이 비치지 않게 하고, hover 시 행 배경(grey_05)을 따른다.
18290
- "overflow-hidden text-ellipsis whitespace-nowrap border-b border-solid border-[color:var(--cmp-table-border-color)] text-[12px] leading-[20px] text-[color:var(--sys-color-fg-primary)]",
18291
- rowBg,
18292
- rowHover && "group-hover/table-row:bg-[var(--color-grey-05)]",
18293
- typeof col.tdClass === "function" ? col.tdClass(row) : col.tdClass
18294
- );
18295
- const tdPadX = cellPadXOf(col);
18296
- const dragEdges = dragSel ? [
18297
- dragTop && `inset 0 ${DRAG_EDGE_W} 0 0 ${DRAG_EDGE_C}`,
18298
- dragBottom && `inset 0 ${DRAG_EDGE_NEG_W} 0 0 ${DRAG_EDGE_C}`,
18299
- i === 0 && `inset ${DRAG_EDGE_W} 0 0 0 ${DRAG_EDGE_C}`,
18300
- i === visibleCols.length - 1 && `inset ${DRAG_EDGE_NEG_W} 0 0 0 ${DRAG_EDGE_C}`
18301
- ].filter(Boolean) : [];
18302
- const shadows = [...dragEdges, stickyShadowValue(i)].filter(Boolean);
18303
- const tdStyle = {
18304
- paddingLeft: tdPadX,
18305
- paddingRight: tdPadX,
18306
- paddingTop: cellPadY,
18307
- paddingBottom: cellPadY,
18308
- textAlign: col.align ?? "left",
18309
- // 인라인이라 hover 배경보다 세다 — 고른 행은 hover 로 흐려지지 않는다.
18310
- background: dragSel ? DRAG_BG : rowLocked ? LOCKED_BG : void 0,
18311
- // 클래스의 기본 글자색을 덮는다. 셀이 자기 색을 정한 내용(SIcon 의
18312
- // color, STag 등)까지는 닿지 않는다 — 그건 소비 앱 몫이다.
18313
- color: rowLocked ? LOCKED_CONTENT : void 0,
18314
- boxShadow: shadows.length > 0 ? shadows.join(", ") : void 0,
18315
- ...cellStyle(i)
18316
- };
18317
- if (col.renderCell) {
18318
- const cell = col.renderCell({
18319
- row,
18320
- value: cellValue(col, row),
18321
- column: col,
18322
- colIndex: i,
18323
- rowIndex: ri,
18324
- isSelected: isSel,
18325
- className: tdClassName,
18326
- style: tdStyle
18327
- });
18328
- if (cell == null || cell === false) return null;
18329
- return /* @__PURE__ */ jsx(Fragment$1, { children: cell }, col.name);
18678
+ )
18330
18679
  }
18331
- return /* @__PURE__ */ jsx("td", { className: tdClassName, style: tdStyle, children: col.render ? col.render(row, cellValue(col, row)) : cellValue(col, row) }, col.name);
18332
- })
18333
- ]
18334
- },
18335
- row[rowKey] ?? ri
18336
- );
18680
+ )
18681
+ }
18682
+ )
18683
+ ] }, row[rowKey] ?? ri);
18337
18684
  }),
18338
18685
  virtualScroll && bottomSpacer > 0 && /* @__PURE__ */ jsx("tr", { "aria-hidden": true, style: { height: bottomSpacer }, children: /* @__PURE__ */ jsx("td", { colSpan: totalColSpan, className: "p-0" }) })
18339
18686
  ] })
@@ -18459,6 +18806,26 @@ var STableImpl = /* @__PURE__ */ forwardRef(function STable({
18459
18806
  );
18460
18807
  });
18461
18808
  var STable2 = STableImpl;
18809
+ function isOwnControl(target, cell) {
18810
+ if (!(target instanceof Element)) return false;
18811
+ if (target.closest("[data-expansion-toggle]")) return false;
18812
+ const control = target.closest(ROW_INTERACTIVE_SELECTOR);
18813
+ return control != null && cell.contains(control);
18814
+ }
18815
+ function ExpansionToggleCell({ expanded, children }) {
18816
+ return /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-(--cmp-tree-item-contentGap)", children: [
18817
+ /* @__PURE__ */ jsx("span", { "data-expansion-toggle": true, className: "flex shrink-0", children: /* @__PURE__ */ jsx(
18818
+ SGhostButton,
18819
+ {
18820
+ icon: expanded ? "caretDown" : "caretRight",
18821
+ size: "xxs",
18822
+ ariaLabel: expanded ? "collapse row" : "expand row",
18823
+ ariaExpanded: expanded
18824
+ }
18825
+ ) }),
18826
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate", children })
18827
+ ] });
18828
+ }
18462
18829
  var COUNT_NULL = "var(--cmp-table-bar-count-null)";
18463
18830
  var totalColor = (n) => n > 0 ? "var(--cmp-table-bar-count-total)" : COUNT_NULL;
18464
18831
  var selectedColor = (n) => n > 0 ? "var(--color-brilliantblue-75)" : COUNT_NULL;
@@ -18527,9 +18894,7 @@ var STableBar = /* @__PURE__ */ forwardRef(function STableBar2({ title, total, s
18527
18894
  }
18528
18895
  );
18529
18896
  });
18530
- var NO_KEYS = /* @__PURE__ */ new Set();
18531
- var EXPAND_MOTION = { duration: 220, easing: "ease-out" };
18532
- var COLLAPSE_MOTION = { duration: 140, easing: "ease-in" };
18897
+ var NO_KEYS2 = /* @__PURE__ */ new Set();
18533
18898
  function STreeTable({
18534
18899
  rows = [],
18535
18900
  columns = [],
@@ -18582,11 +18947,11 @@ function STreeTable({
18582
18947
  [rows, expandedSet, rowKey, childrenKey]
18583
18948
  );
18584
18949
  const tableRef = useRef(null);
18585
- const [closingKeys, setClosingKeys] = useState(NO_KEYS);
18950
+ const [closingKeys, setClosingKeys] = useState(NO_KEYS2);
18586
18951
  const [prevExpand, setPrevExpand] = useState({ rows, expandedSet });
18587
18952
  if (prevExpand.rows !== rows || prevExpand.expandedSet !== expandedSet) {
18588
18953
  setPrevExpand({ rows, expandedSet });
18589
- let nextClosing = NO_KEYS;
18954
+ let nextClosing = NO_KEYS2;
18590
18955
  if (prevExpand.rows === rows && motionAllowed()) {
18591
18956
  const collapsed = [...prevExpand.expandedSet].filter((key) => !expandedSet.has(key));
18592
18957
  const expanded = [...expandedSet].filter((key) => !prevExpand.expandedSet.has(key));
@@ -18635,11 +19000,15 @@ function STreeTable({
18635
19000
  motionRef.current = null;
18636
19001
  }
18637
19002
  const scrollEl = tableRef.current?.getScrollElement();
19003
+ const rowKeys = renderedRows.map(keyOf);
18638
19004
  const keys = added.size > 0 ? added : removed;
18639
19005
  const canAnimate = scrollEl != null && motionAllowed() && (added.size === 0 || removed.size === 0);
18640
19006
  const motion = canAnimate ? startRowMotion({
18641
19007
  scrollEl,
18642
- rowKeys: renderedRows.map(keyOf),
19008
+ isInBlock: (tr) => {
19009
+ const key = rowKeys[Number(tr.dataset.rowIndex)];
19010
+ return key !== void 0 && keys.has(key);
19011
+ },
18643
19012
  blockKeys: keys,
18644
19013
  from,
18645
19014
  to: added.size > 0 ? 1 : 0
@@ -18650,11 +19019,11 @@ function STreeTable({
18650
19019
  void motion.finished.then((completed) => {
18651
19020
  if (!completed || motionRef.current !== motion) return;
18652
19021
  motion.done = true;
18653
- setClosingKeys(NO_KEYS);
19022
+ setClosingKeys(NO_KEYS2);
18654
19023
  });
18655
19024
  }
18656
19025
  } else if (closingKeys.size > 0) {
18657
- setClosingKeys(NO_KEYS);
19026
+ setClosingKeys(NO_KEYS2);
18658
19027
  }
18659
19028
  }, [renderedRows]);
18660
19029
  useEffect(() => () => motionRef.current?.cancel(), []);
@@ -18727,7 +19096,7 @@ function STreeTable({
18727
19096
  className: cn(className, hasChildren && "cursor-pointer"),
18728
19097
  style,
18729
19098
  onClick: hasChildren ? (event) => {
18730
- if (isOwnControl(event.target, event.currentTarget)) return;
19099
+ if (isOwnControl2(event.target, event.currentTarget)) return;
18731
19100
  event.stopPropagation();
18732
19101
  toggleExpanded(row);
18733
19102
  } : void 0,
@@ -18782,96 +19151,8 @@ function flattenRows(nodes, isOpen, childrenOf) {
18782
19151
  walk(nodes);
18783
19152
  return out;
18784
19153
  }
18785
- function sameKeys(a, b) {
18786
- return a.size === b.size && [...a].every((key) => b.has(key));
18787
- }
18788
- function motionAllowed() {
18789
- if (typeof window === "undefined" || typeof Element.prototype.animate !== "function")
18790
- return false;
18791
- return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
18792
- }
18793
- function startRowMotion({
18794
- scrollEl,
18795
- rowKeys,
18796
- blockKeys,
18797
- from,
18798
- to
18799
- }) {
18800
- const spec = to === 1 ? EXPAND_MOTION : COLLAPSE_MOTION;
18801
- const duration = spec.duration * Math.abs(to - from);
18802
- if (duration < 1) return null;
18803
- const trs = [...scrollEl.querySelectorAll("tr[data-row-index]")].map((tr) => {
18804
- const key = rowKeys[Number(tr.dataset.rowIndex)];
18805
- return {
18806
- tr,
18807
- inBlock: key !== void 0 && blockKeys.has(key),
18808
- height: tr.getBoundingClientRect().height
18809
- };
18810
- });
18811
- const viewBottom = scrollEl.scrollTop + scrollEl.clientHeight;
18812
- const openAt = (offset) => from + (to - from) * offset;
18813
- const translate = (shift2, s) => `translateY(${-shift2 * (1 - s)}px)`;
18814
- const timing = {
18815
- duration,
18816
- easing: spec.easing,
18817
- // 접힘은 남겨 둔 행이 빠질 때까지 끝 위치를 붙잡는다 — 놓으면 그 사이 한 프레임 제자리로 튄다
18818
- fill: to === 0 ? "forwards" : "none"
18819
- };
18820
- const animations = [];
18821
- let shift = 0;
18822
- let blockHeight = 0;
18823
- let y = 0;
18824
- trs.forEach(({ tr, inBlock, height }, i) => {
18825
- if (inBlock && !trs[i - 1]?.inBlock) {
18826
- blockHeight = 0;
18827
- for (let j = i; j < trs.length && trs[j].inBlock; j++) blockHeight += trs[j].height;
18828
- shift += blockHeight;
18829
- y = 0;
18830
- }
18831
- if (shift === 0 || tr.offsetTop - shift > viewBottom) return;
18832
- if (!inBlock) {
18833
- animations.push(
18834
- tr.animate(
18835
- [{ transform: translate(shift, from) }, { transform: translate(shift, to) }],
18836
- timing
18837
- )
18838
- );
18839
- return;
18840
- }
18841
- const rowY = y;
18842
- const clipAt = (s) => Math.min(height, Math.max(0, blockHeight * (1 - s) - rowY));
18843
- const bends = [1 - rowY / blockHeight, 1 - (rowY + height) / blockHeight].map((s) => (s - from) / (to - from)).filter((offset) => offset > 0 && offset < 1);
18844
- const offsets = [0, ...bends, 1].sort((a, b) => a - b);
18845
- animations.push(
18846
- tr.animate(
18847
- offsets.map((offset) => ({
18848
- offset,
18849
- transform: translate(shift, openAt(offset)),
18850
- clipPath: `inset(${clipAt(openAt(offset))}px 0 0 0)`
18851
- })),
18852
- timing
18853
- )
18854
- );
18855
- y += height;
18856
- });
18857
- if (animations.length === 0) return null;
18858
- return {
18859
- keys: blockKeys,
18860
- to,
18861
- done: false,
18862
- finished: Promise.all(animations.map((animation) => animation.finished)).then(
18863
- () => true,
18864
- () => false
18865
- ),
18866
- openness: () => {
18867
- const progress = animations[0].effect?.getComputedTiming().progress;
18868
- return progress == null ? to : openAt(progress);
18869
- },
18870
- cancel: () => animations.forEach((animation) => animation.cancel())
18871
- };
18872
- }
18873
19154
  var OWN_CONTROL_SELECTOR = 'a, button, input, select, textarea, label, [role="button"], [role="checkbox"], [contenteditable="true"]';
18874
- function isOwnControl(target, cell) {
19155
+ function isOwnControl2(target, cell) {
18875
19156
  if (!(target instanceof Element)) return false;
18876
19157
  if (target.closest("[data-tree-toggle]")) return false;
18877
19158
  const control = target.closest(OWN_CONTROL_SELECTOR);