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.cjs CHANGED
@@ -7731,6 +7731,13 @@ var TREE_LABEL_TYPOGRAPHY = {
7731
7731
  rowHeight: "h-[32px]"
7732
7732
  }
7733
7733
  };
7734
+ var TREE_BRANCH_MOTION = { duration: 200, easing: "cubic-bezier(0, 0, 0.2, 1)" };
7735
+ function canAnimateTreeBranch() {
7736
+ if (typeof window === "undefined" || typeof HTMLElement.prototype.animate !== "function") {
7737
+ return false;
7738
+ }
7739
+ return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
7740
+ }
7734
7741
  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)";
7735
7742
  var TREE_ROW_BORDER_CLASS = "border-b-(length:--cmp-tree-item-border-width-default) border-(--cmp-tree-item-border-default)";
7736
7743
  var TREE_ROW_ACTIVE_CLASS = "bg-(--cmp-tree-item-bg-hover)";
@@ -15679,6 +15686,93 @@ function SCalendarBoard({
15679
15686
  );
15680
15687
  }
15681
15688
 
15689
+ // src/lib/row-motion.ts
15690
+ var EXPAND_MOTION = { duration: 220, easing: "ease-out" };
15691
+ var COLLAPSE_MOTION = { duration: 140, easing: "ease-in" };
15692
+ function sameKeys(a, b) {
15693
+ return a.size === b.size && [...a].every((key) => b.has(key));
15694
+ }
15695
+ function motionAllowed() {
15696
+ if (typeof window === "undefined" || typeof Element.prototype.animate !== "function")
15697
+ return false;
15698
+ return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
15699
+ }
15700
+ function startRowMotion({
15701
+ scrollEl,
15702
+ isInBlock,
15703
+ blockKeys,
15704
+ from,
15705
+ to
15706
+ }) {
15707
+ const spec = to === 1 ? EXPAND_MOTION : COLLAPSE_MOTION;
15708
+ const duration = spec.duration * Math.abs(to - from);
15709
+ if (duration < 1) return null;
15710
+ const trs = [
15711
+ ...scrollEl.querySelectorAll("tr[data-row-index], tr[data-expansion-row]")
15712
+ ].map((tr) => ({ tr, inBlock: isInBlock(tr), height: tr.getBoundingClientRect().height }));
15713
+ const viewBottom = scrollEl.scrollTop + scrollEl.clientHeight;
15714
+ const openAt = (offset) => from + (to - from) * offset;
15715
+ const translate = (shift2, s) => `translateY(${-shift2 * (1 - s)}px)`;
15716
+ const timing = {
15717
+ duration,
15718
+ easing: spec.easing,
15719
+ // 접힘은 남겨 둔 행이 빠질 때까지 끝 위치를 붙잡는다 — 놓으면 그 사이 한 프레임 제자리로 튄다
15720
+ fill: to === 0 ? "forwards" : "none"
15721
+ };
15722
+ const animations = [];
15723
+ let shift = 0;
15724
+ let blockHeight = 0;
15725
+ let y = 0;
15726
+ trs.forEach(({ tr, inBlock, height }, i) => {
15727
+ if (inBlock && !trs[i - 1]?.inBlock) {
15728
+ blockHeight = 0;
15729
+ for (let j = i; j < trs.length && trs[j].inBlock; j++) blockHeight += trs[j].height;
15730
+ shift += blockHeight;
15731
+ y = 0;
15732
+ }
15733
+ if (shift === 0 || tr.offsetTop - shift > viewBottom) return;
15734
+ if (!inBlock) {
15735
+ animations.push(
15736
+ tr.animate(
15737
+ [{ transform: translate(shift, from) }, { transform: translate(shift, to) }],
15738
+ timing
15739
+ )
15740
+ );
15741
+ return;
15742
+ }
15743
+ const rowY = y;
15744
+ const clipAt = (s) => Math.min(height, Math.max(0, blockHeight * (1 - s) - rowY));
15745
+ const bends = [1 - rowY / blockHeight, 1 - (rowY + height) / blockHeight].map((s) => (s - from) / (to - from)).filter((offset) => offset > 0 && offset < 1);
15746
+ const offsets = [0, ...bends, 1].sort((a, b) => a - b);
15747
+ animations.push(
15748
+ tr.animate(
15749
+ offsets.map((offset) => ({
15750
+ offset,
15751
+ transform: translate(shift, openAt(offset)),
15752
+ clipPath: `inset(${clipAt(openAt(offset))}px 0 0 0)`
15753
+ })),
15754
+ timing
15755
+ )
15756
+ );
15757
+ y += height;
15758
+ });
15759
+ if (animations.length === 0) return null;
15760
+ return {
15761
+ keys: blockKeys,
15762
+ to,
15763
+ done: false,
15764
+ finished: Promise.all(animations.map((animation) => animation.finished)).then(
15765
+ () => true,
15766
+ () => false
15767
+ ),
15768
+ openness: () => {
15769
+ const progress = animations[0].effect?.getComputedTiming().progress;
15770
+ return progress == null ? to : openAt(progress);
15771
+ },
15772
+ cancel: () => animations.forEach((animation) => animation.cancel())
15773
+ };
15774
+ }
15775
+
15682
15776
  // src/lib/row-metrics.ts
15683
15777
  function createRowMetrics(count, estimate) {
15684
15778
  const safeCount = Math.max(0, Math.floor(count));
@@ -16098,6 +16192,7 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16098
16192
  onSearchChange,
16099
16193
  searchDebounce = DEFAULT_SEARCH_DEBOUNCE,
16100
16194
  label,
16195
+ ariaLabel,
16101
16196
  labelPosition,
16102
16197
  subLabel,
16103
16198
  labelWidth,
@@ -16127,6 +16222,21 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16127
16222
  const [focusedIndex, setFocusedIndex] = react.useState(-1);
16128
16223
  const [expanded, setExpanded] = react.useState(() => /* @__PURE__ */ new Set());
16129
16224
  const [searchCollapsed, setSearchCollapsed] = react.useState(NO_SEARCH_COLLAPSED);
16225
+ const [treeMotion, setTreeMotion] = react.useState(null);
16226
+ const treeMotionTimerRef = react.useRef(null);
16227
+ const pendingCollapseRef = react.useRef(null);
16228
+ const cancelTreeMotion = () => {
16229
+ if (treeMotionTimerRef.current != null) clearTimeout(treeMotionTimerRef.current);
16230
+ treeMotionTimerRef.current = null;
16231
+ pendingCollapseRef.current = null;
16232
+ setTreeMotion(null);
16233
+ };
16234
+ react.useEffect(
16235
+ () => () => {
16236
+ if (treeMotionTimerRef.current != null) clearTimeout(treeMotionTimerRef.current);
16237
+ },
16238
+ []
16239
+ );
16130
16240
  const baseId = react.useId();
16131
16241
  const listboxId = `${baseId}-listbox`;
16132
16242
  const activeOptionId = `${baseId}-active-option`;
@@ -16194,6 +16304,7 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16194
16304
  const commitOpen = (next, valueForRules) => {
16195
16305
  setOpen(next);
16196
16306
  onOpenChange?.(next);
16307
+ cancelTreeMotion();
16197
16308
  if (next) {
16198
16309
  dismissedByPointerRef.current = false;
16199
16310
  alignSelectedTopRef.current = true;
@@ -16264,6 +16375,10 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16264
16375
  (groupValue) => treeSearching ? !searchCollapsed.has(groupValue) : expanded.has(groupValue),
16265
16376
  [expanded, searchCollapsed, treeSearching]
16266
16377
  );
16378
+ const isGroupShownExpanded = react.useCallback(
16379
+ (groupValue) => isGroupExpanded(groupValue) && !(treeMotion != null && !treeMotion.opening && treeMotion.group === groupValue),
16380
+ [isGroupExpanded, treeMotion]
16381
+ );
16267
16382
  const treeRows = react.useMemo(
16268
16383
  () => isTree ? visibleTreeRows(searched, (row) => isGroupExpanded(row.value)) : null,
16269
16384
  [isGroupExpanded, isTree, searched]
@@ -16553,6 +16668,7 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16553
16668
  const handleSearchChange = (next) => {
16554
16669
  setSearch(next);
16555
16670
  setSearchCollapsed(NO_SEARCH_COLLAPSED);
16671
+ cancelTreeMotion();
16556
16672
  };
16557
16673
  const handleToggleExpand = (groupValue) => {
16558
16674
  rangeAnchorRef.current = null;
@@ -16562,8 +16678,41 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16562
16678
  else next.add(groupValue);
16563
16679
  return next;
16564
16680
  };
16565
- if (treeSearching) setSearchCollapsed(toggle);
16566
- else setExpanded(toggle);
16681
+ const applyToggle = treeSearching ? () => setSearchCollapsed(toggle) : () => setExpanded(toggle);
16682
+ if (treeMotionTimerRef.current != null) clearTimeout(treeMotionTimerRef.current);
16683
+ treeMotionTimerRef.current = null;
16684
+ const pendingCollapse = pendingCollapseRef.current;
16685
+ pendingCollapseRef.current = null;
16686
+ if (treeMotion?.group === groupValue && !treeMotion.opening) {
16687
+ setTreeMotion({ group: groupValue, opening: true });
16688
+ treeMotionTimerRef.current = setTimeout(
16689
+ () => setTreeMotion(null),
16690
+ TREE_BRANCH_MOTION.duration
16691
+ );
16692
+ return;
16693
+ }
16694
+ pendingCollapse?.();
16695
+ if (!canAnimateTreeBranch()) {
16696
+ setTreeMotion(null);
16697
+ applyToggle();
16698
+ return;
16699
+ }
16700
+ if (isGroupExpanded(groupValue)) {
16701
+ setTreeMotion({ group: groupValue, opening: false });
16702
+ pendingCollapseRef.current = applyToggle;
16703
+ treeMotionTimerRef.current = setTimeout(() => {
16704
+ pendingCollapseRef.current = null;
16705
+ setTreeMotion(null);
16706
+ applyToggle();
16707
+ }, TREE_BRANCH_MOTION.duration);
16708
+ } else {
16709
+ applyToggle();
16710
+ setTreeMotion({ group: groupValue, opening: true });
16711
+ treeMotionTimerRef.current = setTimeout(
16712
+ () => setTreeMotion(null),
16713
+ TREE_BRANCH_MOTION.duration
16714
+ );
16715
+ }
16567
16716
  };
16568
16717
  const handleToggleGroup = (leafValues) => {
16569
16718
  if (leafValues.length === 0) return;
@@ -16673,7 +16822,7 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16673
16822
  const row = rowIndex != null ? filtered[rowIndex] : void 0;
16674
16823
  if (rowIndex == null || row == null) return;
16675
16824
  event.preventDefault();
16676
- const rowExpanded = row.isGroup && isGroupExpanded(row.value);
16825
+ const rowExpanded = row.isGroup && isGroupShownExpanded(row.value);
16677
16826
  if (event.key === "ArrowRight") {
16678
16827
  if (!row.isGroup) return;
16679
16828
  if (!rowExpanded) {
@@ -16746,6 +16895,7 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16746
16895
  style,
16747
16896
  children: [
16748
16897
  name && /* @__PURE__ */ jsxRuntime.jsx("input", { type: "hidden", name, value: selected.join(",") }),
16898
+ !label && ariaLabel && /* @__PURE__ */ jsxRuntime.jsx("span", { id: `${triggerId}-aria-label`, hidden: true, children: ariaLabel }),
16749
16899
  /* @__PURE__ */ jsxRuntime.jsxs(
16750
16900
  "div",
16751
16901
  {
@@ -16754,7 +16904,7 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16754
16904
  role: "combobox",
16755
16905
  tabIndex: effectiveDisabled ? -1 : 0,
16756
16906
  "aria-disabled": effectiveDisabled || void 0,
16757
- "aria-labelledby": label ? `${fieldLabelId(triggerId)} ${triggerId}` : triggerId,
16907
+ "aria-labelledby": label ? `${fieldLabelId(triggerId)} ${triggerId}` : ariaLabel ? `${triggerId}-aria-label ${triggerId}` : triggerId,
16758
16908
  "aria-haspopup": isTree ? "tree" : "listbox",
16759
16909
  "aria-expanded": open,
16760
16910
  "aria-controls": open ? listboxId : void 0,
@@ -16918,8 +17068,9 @@ var SSelectImpl = /* @__PURE__ */ react.forwardRef(function SSelect({
16918
17068
  setSize: isDepth || isTree ? void 0 : rowCount,
16919
17069
  multi,
16920
17070
  tree: isTree,
16921
- isExpanded: isGroupExpanded,
17071
+ isExpanded: isGroupShownExpanded,
16922
17072
  onToggleExpand: handleToggleExpand,
17073
+ treeMotion,
16923
17074
  treeLines,
16924
17075
  selected,
16925
17076
  groupLeafValues,
@@ -16976,6 +17127,7 @@ function ItemList({
16976
17127
  tree,
16977
17128
  isExpanded,
16978
17129
  onToggleExpand,
17130
+ treeMotion,
16979
17131
  treeLines,
16980
17132
  selected,
16981
17133
  groupLeafValues,
@@ -16996,295 +17148,322 @@ function ItemList({
16996
17148
  "disabled:cursor-not-allowed disabled:text-[color:var(--cmp-listBoxItem-content-disabled)]"
16997
17149
  );
16998
17150
  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)]";
16999
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { id: listboxId, role: tree ? "tree" : "listbox", "aria-multiselectable": multi || void 0, children: [
17000
- showSelectAll && (tree ? (
17001
- // 트리의 전체선택 줄은 STree 의 "전체" 줄 그대로다 (lib/tree-row).
17002
- /* @__PURE__ */ jsxRuntime.jsx(
17003
- TreeAllRow,
17004
- {
17005
- "data-select-all-row": true,
17006
- id: focusedValue === "__select_all__" ? activeOptionId : void 0,
17007
- "data-select-focused": focusedValue === "__select_all__" ? "true" : void 0,
17008
- "data-select-active": focusedValue === "__select_all__" ? "true" : void 0,
17009
- tabIndex: -1,
17010
- label: selectAllLabel,
17011
- selected: selectAllSelected,
17012
- active: focusedValue === "__select_all__",
17013
- className: "select-none",
17014
- onMouseEnter: () => onHoverValue("__select_all__"),
17015
- onMouseLeave: () => onHoverValue(null),
17016
- onToggle: onSelectAll
17017
- }
17018
- )
17019
- ) : /* @__PURE__ */ jsxRuntime.jsxs(
17020
- "button",
17151
+ const wrapTreeMotion = (nodes) => {
17152
+ if (!tree || treeMotion == null) return nodes;
17153
+ const start = items.findIndex((o) => o.isGroup && o.value === treeMotion.group);
17154
+ if (start < 0) return nodes;
17155
+ let end = start + 1;
17156
+ while (end < items.length && items[end].depth > items[start].depth) end++;
17157
+ if (end === start + 1) return nodes;
17158
+ return [
17159
+ ...nodes.slice(0, start + 1),
17160
+ /* @__PURE__ */ jsxRuntime.jsx(TreeRowsMotion, { opening: treeMotion.opening, children: nodes.slice(start + 1, end) }, `__motion_${treeMotion.group}__`),
17161
+ ...nodes.slice(end)
17162
+ ];
17163
+ };
17164
+ const selectAllRow = showSelectAll && (tree ? (
17165
+ // 트리의 전체선택 줄은 STree 의 "전체" 줄 그대로다 (lib/tree-row).
17166
+ /* @__PURE__ */ jsxRuntime.jsx(
17167
+ TreeAllRow,
17021
17168
  {
17022
- type: "button",
17023
17169
  "data-select-all-row": true,
17024
17170
  id: focusedValue === "__select_all__" ? activeOptionId : void 0,
17025
17171
  "data-select-focused": focusedValue === "__select_all__" ? "true" : void 0,
17026
17172
  "data-select-active": focusedValue === "__select_all__" ? "true" : void 0,
17027
- className: cn(
17028
- itemBase,
17029
- focusedValue === "__select_all__" && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17030
- ),
17031
- style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: LIST_BOX_ITEM_PADDING_LEFT[1] },
17173
+ tabIndex: -1,
17174
+ label: selectAllLabel,
17175
+ selected: selectAllSelected,
17176
+ active: focusedValue === "__select_all__",
17177
+ className: "select-none",
17032
17178
  onMouseEnter: () => onHoverValue("__select_all__"),
17033
17179
  onMouseLeave: () => onHoverValue(null),
17034
- onClick: onSelectAll,
17035
- children: [
17036
- /* @__PURE__ */ jsxRuntime.jsx(CheckboxBox, { state: selectAllSelected, focused: focusedValue === "__select_all__" }),
17037
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex-1 truncate", children: selectAllLabel })
17038
- ]
17180
+ onToggle: onSelectAll
17039
17181
  }
17040
- )),
17041
- topSpacer > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { "aria-hidden": true, style: { height: topSpacer } }),
17042
- items.map((o, windowIndex) => {
17043
- const idx = windowIndex + indexOffset;
17044
- const padLeft = LIST_BOX_ITEM_PADDING_LEFT[o.depth === 1 ? 1 : o.depth === 2 ? 2 : 3];
17045
- const rowKey = `${o.value}-${idx}`;
17046
- if (tree) {
17047
- const togglesLeaves = multi && o.isGroup;
17048
- const leafValues = togglesLeaves ? groupLeafValues.get(idx) ?? [] : [];
17049
- const total = leafValues.length;
17050
- const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17051
- const isSelected2 = selectedSet.has(o.value);
17052
- const checkState = togglesLeaves ? total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null : isSelected2;
17053
- const rowDisabled = o.disabled || togglesLeaves && total === 0;
17054
- const focusKey = togglesLeaves ? `__group_${idx}__` : o.value;
17055
- const focused = focusedValue === focusKey;
17056
- const expanded = o.isGroup && isExpanded(o.value);
17057
- const line = treeLines[idx];
17058
- const toggle = () => {
17059
- if (rowDisabled) return;
17060
- if (togglesLeaves) onToggleGroup(leafValues);
17061
- else onSelect(o.value, idx, checkboxShiftRef.current);
17062
- };
17063
- return (
17064
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- 키보드는 트리거(또는 검색 입력)가 aria-activedescendant 로 맡는다. 줄로 포커스를 옮기지 않는다
17065
- /* @__PURE__ */ jsxRuntime.jsxs(
17066
- "div",
17067
- {
17068
- tabIndex: -1,
17069
- "data-select-row": true,
17070
- "data-index": idx,
17071
- id: focused ? activeOptionId : void 0,
17072
- role: "treeitem",
17073
- "aria-level": o.depth,
17074
- "aria-posinset": line?.posInSet,
17075
- "aria-setsize": line?.setSize,
17076
- "aria-expanded": o.isGroup ? expanded : void 0,
17077
- "aria-selected": checkState === true,
17078
- "aria-disabled": rowDisabled || void 0,
17079
- "data-select-focused": focused ? "true" : void 0,
17080
- "data-select-active": focused ? "true" : void 0,
17081
- className: cn(
17082
- TREE_ROW_CLASS,
17083
- TREE_LABEL_TYPOGRAPHY.sm.rowHeight,
17084
- TREE_ROW_BORDER_CLASS,
17085
- "select-none",
17086
- o.isGroup && "cursor-pointer",
17087
- focused && TREE_ROW_ACTIVE_CLASS
17088
- ),
17089
- onMouseEnter: () => onHoverValue(focusKey),
17090
- onMouseLeave: () => onHoverValue(null),
17091
- onClick: (event) => {
17092
- if (isRowControlClick(event)) return;
17093
- if (isRowSelectClick(event)) {
17094
- if (rowDisabled) return;
17095
- if (togglesLeaves) onToggleGroup(leafValues);
17096
- else onSelect(o.value, idx, event.shiftKey);
17097
- return;
17098
- }
17099
- if (o.isGroup) onToggleExpand(o.value);
17100
- },
17101
- children: [
17102
- /* @__PURE__ */ jsxRuntime.jsx(
17103
- TreeConnector,
17104
- {
17105
- guideline: true,
17106
- size: "sm",
17107
- depth: o.depth,
17108
- hasChildren: o.isGroup,
17109
- isLast: line?.isLast ?? true,
17110
- ancestorLast: line?.ancestorLast ?? []
17111
- }
17112
- ),
17113
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: TREE_CONTENT_CLASS, children: [
17114
- o.isGroup ? (
17115
- // 비활성 그룹도 접고 편다 — 그룹이 비활성이어도 아래에 고를 수 있는 자식이 있을 수 있다.
17116
- /* @__PURE__ */ jsxRuntime.jsx(TreeCaret, { expanded, onToggle: () => onToggleExpand(o.value) })
17117
- ) : /* @__PURE__ */ jsxRuntime.jsx(TreeCaretSpacer, {}),
17118
- /* @__PURE__ */ jsxRuntime.jsx(
17119
- TreeSelectZone,
17120
- {
17121
- selectable: true,
17122
- disabled: rowDisabled,
17123
- control: multi && /* @__PURE__ */ jsxRuntime.jsx(
17124
- "span",
17125
- {
17126
- "data-tree-control": true,
17127
- className: "flex shrink-0",
17128
- onPointerDownCapture: (event) => {
17129
- checkboxShiftRef.current = event.shiftKey;
17130
- },
17131
- children: /* @__PURE__ */ jsxRuntime.jsx(
17132
- SCheckbox,
17133
- {
17134
- value: checkState,
17135
- disabled: rowDisabled,
17136
- onValueChange: toggle
17137
- }
17138
- )
17139
- }
17140
- ),
17141
- children: /* @__PURE__ */ jsxRuntime.jsx(
17142
- OptionLabel,
17143
- {
17144
- label: o.label,
17145
- className: treeLabelClass("sm", {
17146
- // 고른 줄은 굵게 — multi_tree 는 일부만 고른 그룹도 굵다 (STree 와 같다)
17147
- emphasized: checkState !== false,
17148
- disabled: rowDisabled,
17149
- selectable: true
17150
- })
17151
- }
17152
- )
17153
- }
17154
- )
17155
- ] })
17156
- ]
17182
+ )
17183
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(
17184
+ "button",
17185
+ {
17186
+ type: "button",
17187
+ "data-select-all-row": true,
17188
+ id: focusedValue === "__select_all__" ? activeOptionId : void 0,
17189
+ "data-select-focused": focusedValue === "__select_all__" ? "true" : void 0,
17190
+ "data-select-active": focusedValue === "__select_all__" ? "true" : void 0,
17191
+ className: cn(
17192
+ itemBase,
17193
+ focusedValue === "__select_all__" && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17194
+ ),
17195
+ style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: LIST_BOX_ITEM_PADDING_LEFT[1] },
17196
+ onMouseEnter: () => onHoverValue("__select_all__"),
17197
+ onMouseLeave: () => onHoverValue(null),
17198
+ onClick: onSelectAll,
17199
+ children: [
17200
+ /* @__PURE__ */ jsxRuntime.jsx(CheckboxBox, { state: selectAllSelected, focused: focusedValue === "__select_all__" }),
17201
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex-1 truncate", children: selectAllLabel })
17202
+ ]
17203
+ }
17204
+ ));
17205
+ const rowNodes = items.map((o, windowIndex) => {
17206
+ const idx = windowIndex + indexOffset;
17207
+ const padLeft = LIST_BOX_ITEM_PADDING_LEFT[o.depth === 1 ? 1 : o.depth === 2 ? 2 : 3];
17208
+ const rowKey = `${o.value}-${idx}`;
17209
+ if (tree) {
17210
+ const togglesLeaves = multi && o.isGroup;
17211
+ const leafValues = togglesLeaves ? groupLeafValues.get(idx) ?? [] : [];
17212
+ const total = leafValues.length;
17213
+ const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17214
+ const isSelected2 = selectedSet.has(o.value);
17215
+ const checkState = togglesLeaves ? total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null : isSelected2;
17216
+ const rowDisabled = o.disabled || togglesLeaves && total === 0;
17217
+ const focusKey = togglesLeaves ? `__group_${idx}__` : o.value;
17218
+ const focused = focusedValue === focusKey;
17219
+ const expanded = o.isGroup && isExpanded(o.value);
17220
+ const line = treeLines[idx];
17221
+ const toggle = () => {
17222
+ if (rowDisabled) return;
17223
+ if (togglesLeaves) onToggleGroup(leafValues);
17224
+ else onSelect(o.value, idx, checkboxShiftRef.current);
17225
+ };
17226
+ return (
17227
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- 키보드는 트리거(또는 검색 입력)가 aria-activedescendant 로 맡는다. 줄로 포커스를 옮기지 않는다
17228
+ /* @__PURE__ */ jsxRuntime.jsxs(
17229
+ "div",
17230
+ {
17231
+ tabIndex: -1,
17232
+ "data-select-row": true,
17233
+ "data-index": idx,
17234
+ id: focused ? activeOptionId : void 0,
17235
+ role: "treeitem",
17236
+ "aria-level": o.depth,
17237
+ "aria-posinset": line?.posInSet,
17238
+ "aria-setsize": line?.setSize,
17239
+ "aria-expanded": o.isGroup ? expanded : void 0,
17240
+ "aria-selected": checkState === true,
17241
+ "aria-disabled": rowDisabled || void 0,
17242
+ "data-select-focused": focused ? "true" : void 0,
17243
+ "data-select-active": focused ? "true" : void 0,
17244
+ className: cn(
17245
+ TREE_ROW_CLASS,
17246
+ TREE_LABEL_TYPOGRAPHY.sm.rowHeight,
17247
+ TREE_ROW_BORDER_CLASS,
17248
+ "select-none",
17249
+ o.isGroup && "cursor-pointer",
17250
+ focused && TREE_ROW_ACTIVE_CLASS
17251
+ ),
17252
+ onMouseEnter: () => onHoverValue(focusKey),
17253
+ onMouseLeave: () => onHoverValue(null),
17254
+ onClick: (event) => {
17255
+ if (isRowControlClick(event)) return;
17256
+ if (isRowSelectClick(event)) {
17257
+ if (rowDisabled) return;
17258
+ if (togglesLeaves) onToggleGroup(leafValues);
17259
+ else onSelect(o.value, idx, event.shiftKey);
17260
+ return;
17261
+ }
17262
+ if (o.isGroup) onToggleExpand(o.value);
17157
17263
  },
17158
- rowKey
17159
- )
17160
- );
17161
- }
17162
- if (o.isGroup) {
17163
- const isDepth1 = o.depth === 1;
17164
- const isFirstRow = idx === 0 && !showSelectAll;
17165
- const groupTone = cn(
17166
- isDepth1 ? "bg-[var(--cmp-listBoxItem-depth1-bg-default)] font-bold" : "bg-[var(--cmp-listBoxItem-depth2-middle-bg)] font-medium",
17167
- isDepth1 && !isFirstRow && "border-t border-solid border-[color:var(--cmp-listBoxItem-depth1-border-color)]"
17168
- );
17169
- if (multi) {
17170
- const leafValues = groupLeafValues.get(idx) ?? [];
17171
- const total = leafValues.length;
17172
- const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17173
- const groupState = total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null;
17174
- const groupDisabled = o.disabled || total === 0;
17175
- const groupFocused = focusedValue === `__group_${idx}__`;
17176
- return /* @__PURE__ */ jsxRuntime.jsxs(
17177
- "button",
17178
- {
17179
- type: "button",
17180
- "data-select-row": true,
17181
- "data-index": idx,
17182
- id: groupFocused ? activeOptionId : void 0,
17183
- role: "option",
17184
- "aria-selected": groupState === true,
17185
- disabled: groupDisabled,
17186
- "data-select-focused": groupFocused ? "true" : void 0,
17187
- "data-select-active": groupFocused ? "true" : void 0,
17188
- className: cn(
17189
- "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)]",
17190
- groupTone,
17191
- groupDisabled ? "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]" : "cursor-pointer",
17192
- groupDisabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]",
17193
- // 포커스 하이라이트는 depth 배경보다 뒤에 둬 tailwind-merge 로 이기게 한다.
17194
- groupFocused && !groupDisabled && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17264
+ children: [
17265
+ /* @__PURE__ */ jsxRuntime.jsx(
17266
+ TreeConnector,
17267
+ {
17268
+ guideline: true,
17269
+ size: "sm",
17270
+ depth: o.depth,
17271
+ hasChildren: o.isGroup,
17272
+ isLast: line?.isLast ?? true,
17273
+ ancestorLast: line?.ancestorLast ?? []
17274
+ }
17195
17275
  ),
17196
- style: { paddingLeft: padLeft },
17197
- onMouseEnter: () => onHoverValue(`__group_${idx}__`),
17198
- onMouseLeave: () => onHoverValue(null),
17199
- onClick: () => onToggleGroup(leafValues),
17200
- children: [
17201
- /* @__PURE__ */ jsxRuntime.jsx(CheckboxBox, { state: groupState, disabled: groupDisabled, focused: groupFocused }),
17202
- /* @__PURE__ */ jsxRuntime.jsx(OptionLabel, { label: o.label, className: "min-w-0 truncate" }),
17203
- total > 0 && /* @__PURE__ */ jsxRuntime.jsxs(
17204
- "span",
17276
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: TREE_CONTENT_CLASS, children: [
17277
+ o.isGroup ? (
17278
+ // 비활성 그룹도 접고 편다 — 그룹이 비활성이어도 아래에 고를 수 있는 자식이 있을 수 있다.
17279
+ /* @__PURE__ */ jsxRuntime.jsx(TreeCaret, { expanded, onToggle: () => onToggleExpand(o.value) })
17280
+ ) : /* @__PURE__ */ jsxRuntime.jsx(TreeCaretSpacer, {}),
17281
+ /* @__PURE__ */ jsxRuntime.jsx(
17282
+ TreeSelectZone,
17205
17283
  {
17206
- className: cn(
17207
- "shrink-0 font-medium",
17208
- groupFocused ? "text-white" : "text-[color:var(--cmp-listBoxItem-content-disabled)]"
17284
+ selectable: true,
17285
+ disabled: rowDisabled,
17286
+ control: multi && /* @__PURE__ */ jsxRuntime.jsx(
17287
+ "span",
17288
+ {
17289
+ "data-tree-control": true,
17290
+ className: "flex shrink-0",
17291
+ onPointerDownCapture: (event) => {
17292
+ checkboxShiftRef.current = event.shiftKey;
17293
+ },
17294
+ children: /* @__PURE__ */ jsxRuntime.jsx(SCheckbox, { value: checkState, disabled: rowDisabled, onValueChange: toggle })
17295
+ }
17209
17296
  ),
17210
- children: [
17211
- "(",
17212
- selectedCount,
17213
- "/",
17214
- total,
17215
- ")"
17216
- ]
17297
+ children: /* @__PURE__ */ jsxRuntime.jsx(
17298
+ OptionLabel,
17299
+ {
17300
+ label: o.label,
17301
+ className: treeLabelClass("sm", {
17302
+ // 고른 줄은 굵게 — multi_tree 는 일부만 고른 그룹도 굵다 (STree 와 같다)
17303
+ emphasized: checkState !== false,
17304
+ disabled: rowDisabled,
17305
+ selectable: true
17306
+ })
17307
+ }
17308
+ )
17217
17309
  }
17218
17310
  )
17219
- ]
17220
- },
17221
- rowKey
17222
- );
17223
- }
17224
- return /* @__PURE__ */ jsxRuntime.jsx(
17225
- "div",
17311
+ ] })
17312
+ ]
17313
+ },
17314
+ rowKey
17315
+ )
17316
+ );
17317
+ }
17318
+ if (o.isGroup) {
17319
+ const isDepth1 = o.depth === 1;
17320
+ const isFirstRow = idx === 0 && !showSelectAll;
17321
+ const groupTone = cn(
17322
+ isDepth1 ? "bg-[var(--cmp-listBoxItem-depth1-bg-default)] font-bold" : "bg-[var(--cmp-listBoxItem-depth2-middle-bg)] font-medium",
17323
+ isDepth1 && !isFirstRow && "border-t border-solid border-[color:var(--cmp-listBoxItem-depth1-border-color)]"
17324
+ );
17325
+ if (multi) {
17326
+ const leafValues = groupLeafValues.get(idx) ?? [];
17327
+ const total = leafValues.length;
17328
+ const selectedCount = leafValues.reduce((n, v) => selectedSet.has(v) ? n + 1 : n, 0);
17329
+ const groupState = total === 0 || selectedCount === 0 ? false : selectedCount === total ? true : null;
17330
+ const groupDisabled = o.disabled || total === 0;
17331
+ const groupFocused = focusedValue === `__group_${idx}__`;
17332
+ return /* @__PURE__ */ jsxRuntime.jsxs(
17333
+ "button",
17226
17334
  {
17335
+ type: "button",
17227
17336
  "data-select-row": true,
17228
17337
  "data-index": idx,
17338
+ id: groupFocused ? activeOptionId : void 0,
17339
+ role: "option",
17340
+ "aria-selected": groupState === true,
17341
+ disabled: groupDisabled,
17342
+ "data-select-focused": groupFocused ? "true" : void 0,
17343
+ "data-select-active": groupFocused ? "true" : void 0,
17229
17344
  className: cn(
17230
- "select-none py-(--cmp-listBoxItem-paddingY) pr-(--cmp-listBoxItem-paddingRight) typo-field-sm-default text-[color:var(--cmp-listBoxItem-content-default)]",
17345
+ "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)]",
17231
17346
  groupTone,
17232
- // 하위가 전부 비활성이면(select.config 에서 bubble-up) 어미 그룹도 비활성 처리:
17233
- // 텍스트는 회색(#888), depth1 그룹은 파란 배경 대신 회색(#EEE)으로 (depth2 는 #F6F6F6 유지)
17234
- o.disabled && "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]",
17235
- o.disabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]"
17347
+ groupDisabled ? "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]" : "cursor-pointer",
17348
+ groupDisabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]",
17349
+ // 포커스 하이라이트는 depth 배경보다 뒤에 둬 tailwind-merge 로 이기게 한다.
17350
+ groupFocused && !groupDisabled && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]"
17236
17351
  ),
17237
17352
  style: { paddingLeft: padLeft },
17238
- children: o.label
17353
+ onMouseEnter: () => onHoverValue(`__group_${idx}__`),
17354
+ onMouseLeave: () => onHoverValue(null),
17355
+ onClick: () => onToggleGroup(leafValues),
17356
+ children: [
17357
+ /* @__PURE__ */ jsxRuntime.jsx(CheckboxBox, { state: groupState, disabled: groupDisabled, focused: groupFocused }),
17358
+ /* @__PURE__ */ jsxRuntime.jsx(OptionLabel, { label: o.label, className: "min-w-0 truncate" }),
17359
+ total > 0 && /* @__PURE__ */ jsxRuntime.jsxs(
17360
+ "span",
17361
+ {
17362
+ className: cn(
17363
+ "shrink-0 font-medium",
17364
+ groupFocused ? "text-white" : "text-[color:var(--cmp-listBoxItem-content-disabled)]"
17365
+ ),
17366
+ children: [
17367
+ "(",
17368
+ selectedCount,
17369
+ "/",
17370
+ total,
17371
+ ")"
17372
+ ]
17373
+ }
17374
+ )
17375
+ ]
17239
17376
  },
17240
17377
  rowKey
17241
17378
  );
17242
17379
  }
17243
- const isSelected = selected.includes(o.value);
17244
- return /* @__PURE__ */ jsxRuntime.jsxs(
17245
- "button",
17380
+ return /* @__PURE__ */ jsxRuntime.jsx(
17381
+ "div",
17246
17382
  {
17247
- type: "button",
17248
17383
  "data-select-row": true,
17249
17384
  "data-index": idx,
17250
- id: focusedValue === o.value ? activeOptionId : void 0,
17251
- disabled: o.disabled,
17252
- role: "option",
17253
- "aria-selected": isSelected,
17254
- "aria-setsize": setSize,
17255
- "aria-posinset": setSize != null ? idx + 1 : void 0,
17256
- "data-select-focused": focusedValue === o.value ? "true" : void 0,
17257
- "data-select-active": focusedValue === o.value ? "true" : void 0,
17258
17385
  className: cn(
17259
- itemBase,
17260
- isSelected && !multi && "font-bold text-[color:var(--cmp-listBoxItem-content-selected)]",
17261
- focusedValue === o.value && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]",
17262
- // 비활성 항목은 hover/focus 스타일 대상에서 제외한다.
17263
- // (:hover 는 disabled 버튼에도 매칭되므로 클래스 자체를 붙이지 않아야 한다)
17264
- !o.disabled && activeClass
17386
+ "select-none py-(--cmp-listBoxItem-paddingY) pr-(--cmp-listBoxItem-paddingRight) typo-field-sm-default text-[color:var(--cmp-listBoxItem-content-default)]",
17387
+ groupTone,
17388
+ // 하위가 전부 비활성이면(select.config 에서 bubble-up) 어미 그룹도 비활성 처리:
17389
+ // 텍스트는 회색(#888), depth1 그룹은 파란 배경 대신 회색(#EEE)으로 (depth2 는 #F6F6F6 유지)
17390
+ o.disabled && "cursor-not-allowed text-[color:var(--cmp-listBoxItem-content-disabled)]",
17391
+ o.disabled && isDepth1 && "bg-[var(--cmp-listBoxItem-depth1-bg-disabled)]"
17265
17392
  ),
17266
- style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: padLeft },
17267
- onMouseEnter: () => onHoverValue(o.value),
17268
- onMouseLeave: () => onHoverValue(null),
17269
- onClick: (event) => onSelect(o.value, idx, event.shiftKey),
17270
- children: [
17271
- multi && /* @__PURE__ */ jsxRuntime.jsx(
17272
- CheckboxBox,
17273
- {
17274
- state: isSelected,
17275
- disabled: o.disabled,
17276
- focused: focusedValue === o.value
17277
- }
17278
- ),
17279
- /* @__PURE__ */ jsxRuntime.jsx(OptionLabel, { label: o.label, className: "flex-1 truncate" })
17280
- ]
17393
+ style: { paddingLeft: padLeft },
17394
+ children: o.label
17281
17395
  },
17282
17396
  rowKey
17283
17397
  );
17284
- }),
17398
+ }
17399
+ const isSelected = selected.includes(o.value);
17400
+ return /* @__PURE__ */ jsxRuntime.jsxs(
17401
+ "button",
17402
+ {
17403
+ type: "button",
17404
+ "data-select-row": true,
17405
+ "data-index": idx,
17406
+ id: focusedValue === o.value ? activeOptionId : void 0,
17407
+ disabled: o.disabled,
17408
+ role: "option",
17409
+ "aria-selected": isSelected,
17410
+ "aria-setsize": setSize,
17411
+ "aria-posinset": setSize != null ? idx + 1 : void 0,
17412
+ "data-select-focused": focusedValue === o.value ? "true" : void 0,
17413
+ "data-select-active": focusedValue === o.value ? "true" : void 0,
17414
+ className: cn(
17415
+ itemBase,
17416
+ isSelected && !multi && "font-bold text-[color:var(--cmp-listBoxItem-content-selected)]",
17417
+ focusedValue === o.value && "bg-[var(--cmp-listBoxItem-bg-hover)] text-[color:var(--cmp-listBoxItem-content-hover)]",
17418
+ // 비활성 항목은 hover/focus 스타일 대상에서 제외한다.
17419
+ // (:hover 는 disabled 버튼에도 매칭되므로 클래스 자체를 붙이지 않아야 한다)
17420
+ !o.disabled && activeClass
17421
+ ),
17422
+ style: { ...LIST_BOX_ITEM_PADDING_Y_RIGHT, paddingLeft: padLeft },
17423
+ onMouseEnter: () => onHoverValue(o.value),
17424
+ onMouseLeave: () => onHoverValue(null),
17425
+ onClick: (event) => onSelect(o.value, idx, event.shiftKey),
17426
+ children: [
17427
+ multi && /* @__PURE__ */ jsxRuntime.jsx(
17428
+ CheckboxBox,
17429
+ {
17430
+ state: isSelected,
17431
+ disabled: o.disabled,
17432
+ focused: focusedValue === o.value
17433
+ }
17434
+ ),
17435
+ /* @__PURE__ */ jsxRuntime.jsx(OptionLabel, { label: o.label, className: "flex-1 truncate" })
17436
+ ]
17437
+ },
17438
+ rowKey
17439
+ );
17440
+ });
17441
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { id: listboxId, role: tree ? "tree" : "listbox", "aria-multiselectable": multi || void 0, children: [
17442
+ selectAllRow,
17443
+ topSpacer > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { "aria-hidden": true, style: { height: topSpacer } }),
17444
+ wrapTreeMotion(rowNodes),
17285
17445
  bottomSpacer > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { "aria-hidden": true, style: { height: bottomSpacer } })
17286
17446
  ] });
17287
17447
  }
17448
+ function TreeRowsMotion({ opening, children }) {
17449
+ const ref = react.useRef(null);
17450
+ const animationRef = react.useRef(null);
17451
+ const mountedRef = react.useRef(false);
17452
+ react.useLayoutEffect(() => {
17453
+ const el = ref.current;
17454
+ if (el == null || typeof el.animate !== "function") return;
17455
+ const from = !mountedRef.current && opening ? 0 : el.getBoundingClientRect().height;
17456
+ mountedRef.current = true;
17457
+ animationRef.current?.cancel();
17458
+ animationRef.current = el.animate(
17459
+ [{ height: `${from}px` }, { height: opening ? `${el.scrollHeight}px` : "0px" }],
17460
+ // 접힌 뒤 줄이 목록에서 빠질 때까지 0 으로 붙잡아 둔다. 편 뒤에는 제 높이(auto)로 돌아간다
17461
+ { ...TREE_BRANCH_MOTION, fill: opening ? "none" : "forwards" }
17462
+ );
17463
+ }, [opening]);
17464
+ react.useEffect(() => () => animationRef.current?.cancel(), []);
17465
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { ref, className: "overflow-hidden", "aria-hidden": opening ? void 0 : true, children });
17466
+ }
17288
17467
  function OptionLabel({ label, className }) {
17289
17468
  return isHtmlLabel(label) ? /* @__PURE__ */ jsxRuntime.jsx("span", { className, dangerouslySetInnerHTML: { __html: sanitizeInlineHtml(label) } }) : /* @__PURE__ */ jsxRuntime.jsx("span", { className, children: label });
17290
17469
  }
@@ -17333,6 +17512,7 @@ function CheckboxBox({
17333
17512
  }
17334
17513
  );
17335
17514
  }
17515
+ var NO_KEYS = /* @__PURE__ */ new Set();
17336
17516
  var DEFAULT_ROW_HEIGHT = 44;
17337
17517
  var DENSE_ROW_HEIGHT = 32;
17338
17518
  var SELECTABLE_COLUMN_WIDTH = 48;
@@ -17363,6 +17543,11 @@ var CONTEXT_MENU_ITEM_CLASS = cn(
17363
17543
  "data-[highlighted]:bg-[var(--sys-color-bg-accent)] data-[highlighted]:text-[color:var(--sys-color-fg-inverse)]"
17364
17544
  );
17365
17545
  var ROW_INTERACTIVE_SELECTOR = 'button, a, input, select, textarea, label, [role="button"], [role="checkbox"], [contenteditable="true"]';
17546
+ function rowBlockHeight(tr) {
17547
+ const next = tr.nextElementSibling;
17548
+ const expansion = next instanceof HTMLElement && next.dataset.expansionRow != null ? next.offsetHeight : 0;
17549
+ return tr.offsetHeight + expansion;
17550
+ }
17366
17551
  var colWidthFromDef = (c) => {
17367
17552
  if (c.autoWidth) return 0;
17368
17553
  if (c.width == null) return DEFAULT_COLUMN_WIDTH;
@@ -17375,6 +17560,8 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17375
17560
  rows = [],
17376
17561
  rowKey = "id",
17377
17562
  selectable = false,
17563
+ rowSelectLabel,
17564
+ selectAllLabel = "\uC804\uCCB4 \uC120\uD0DD",
17378
17565
  dragSelectable = false,
17379
17566
  contextMenuItems,
17380
17567
  onContextMenuItemClick,
@@ -17413,6 +17600,11 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17413
17600
  tableId,
17414
17601
  onRowClick,
17415
17602
  onRowDoubleClick,
17603
+ renderExpansion,
17604
+ expansionColumn,
17605
+ expandedKeys,
17606
+ defaultExpandedKeys,
17607
+ onExpandedChange,
17416
17608
  className,
17417
17609
  style
17418
17610
  }, ref) {
@@ -17427,6 +17619,44 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17427
17619
  onDenseChange?.(next);
17428
17620
  };
17429
17621
  const visibleCols = react.useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
17622
+ const expansionOn = renderExpansion != null;
17623
+ const [innerExpandedKeys, setInnerExpandedKeys] = react.useState(
17624
+ () => defaultExpandedKeys ?? []
17625
+ );
17626
+ const currentExpandedKeys = expandedKeys ?? innerExpandedKeys;
17627
+ const expandedSet = react.useMemo(() => new Set(currentExpandedKeys), [currentExpandedKeys]);
17628
+ const isRowExpanded = (row) => expansionOn && expandedSet.has(row[rowKey]);
17629
+ const [closingKeys, setClosingKeys] = react.useState(NO_KEYS);
17630
+ const [prevExpandedSet, setPrevExpandedSet] = react.useState(expandedSet);
17631
+ if (prevExpandedSet !== expandedSet) {
17632
+ setPrevExpandedSet(expandedSet);
17633
+ let nextClosing = NO_KEYS;
17634
+ if (expansionOn && motionAllowed()) {
17635
+ const collapsed = [...prevExpandedSet].filter((key) => !expandedSet.has(key));
17636
+ const opened = [...expandedSet].filter((key) => !prevExpandedSet.has(key));
17637
+ if (collapsed.length > 0 && opened.length === 0) nextClosing = new Set(collapsed);
17638
+ }
17639
+ if (nextClosing !== closingKeys) setClosingKeys(nextClosing);
17640
+ }
17641
+ const shownExpansionSet = react.useMemo(
17642
+ () => closingKeys.size === 0 ? expandedSet : /* @__PURE__ */ new Set([...expandedSet, ...closingKeys]),
17643
+ [expandedSet, closingKeys]
17644
+ );
17645
+ const toggleExpanded = (row) => {
17646
+ const key = row[rowKey];
17647
+ const next = expandedSet.has(key) ? currentExpandedKeys.filter((k) => k !== key) : [...currentExpandedKeys, key];
17648
+ if (expandedKeys === void 0) setInnerExpandedKeys(next);
17649
+ onExpandedChange?.(next, row);
17650
+ };
17651
+ const expansionColIndex = !expansionOn ? -1 : expansionColumn == null ? 0 : visibleCols.findIndex((c) => c.name === expansionColumn);
17652
+ if (expansionColIndex >= 0) {
17653
+ const col = visibleCols[expansionColIndex];
17654
+ if (col?.renderCell) {
17655
+ throw new Error(
17656
+ `[STable] \uD3BC\uCE68 \uCEEC\uB7FC(${col.name})\uC5D0\uB294 renderCell \uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. render \uB97C \uC4F0\uC138\uC694.`
17657
+ );
17658
+ }
17659
+ }
17430
17660
  const headerGroupInfo = react.useMemo(() => {
17431
17661
  if (!headerGroups || headerGroups.length === 0) return null;
17432
17662
  const indexByName = new Map(visibleCols.map((col, index) => [col.name, index]));
@@ -17531,6 +17761,7 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17531
17761
  metricsRef.current ?? (metricsRef.current = createRowMetrics(0, effectiveRowHeight));
17532
17762
  const metrics = metricsRef.current;
17533
17763
  const measuredKeyRef = react.useRef("");
17764
+ const measuredExpansionRef = react.useRef(shownExpansionSet);
17534
17765
  const pendingRowRef = react.useRef(null);
17535
17766
  const pinnedTopRef = react.useRef(-1);
17536
17767
  const lastEstimateRef = react.useRef(effectiveRowHeight);
@@ -17544,6 +17775,7 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17544
17775
  }
17545
17776
  const [scrolledLeft, setScrolledLeft] = react.useState(false);
17546
17777
  const [scrolledRight, setScrolledRight] = react.useState(false);
17778
+ const [viewportWidth, setViewportWidth] = react.useState(0);
17547
17779
  const setRowCount = react.useCallback((count) => {
17548
17780
  const safe = Math.max(0, Math.floor(Number.isFinite(count) ? count : 0));
17549
17781
  lastReachEndRef.current = -1;
@@ -17569,6 +17801,7 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17569
17801
  setHeaderHeight(headerH);
17570
17802
  setScrolledLeft(el.scrollLeft > 0);
17571
17803
  setScrolledRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
17804
+ setViewportWidth(el.clientWidth);
17572
17805
  if (virtualScroll) {
17573
17806
  const { start, end } = calcRange(el.scrollTop, el.clientHeight - headerH);
17574
17807
  setVsRange((prev) => prev.start === start && prev.end === end ? prev : { start, end });
@@ -17586,6 +17819,13 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17586
17819
  react.useEffect(() => {
17587
17820
  handleScroll();
17588
17821
  }, [handleScroll, rowCount, columnWidths, loading2, height]);
17822
+ react.useEffect(() => {
17823
+ const el = scrollRef.current;
17824
+ if (!expansionOn || !el || typeof ResizeObserver === "undefined") return;
17825
+ const observer = new ResizeObserver(() => handleScroll());
17826
+ observer.observe(el);
17827
+ return () => observer.disconnect();
17828
+ }, [expansionOn, handleScroll]);
17589
17829
  const handleScrollRef = react.useRef(handleScroll);
17590
17830
  handleScrollRef.current = handleScroll;
17591
17831
  useIsomorphicLayoutEffect(() => {
@@ -17595,9 +17835,10 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17595
17835
  if (!el || m == null) return;
17596
17836
  const pending2 = pendingRowRef.current;
17597
17837
  const key = `${vsRange.start}:${vsRange.end}:${rowCount}`;
17598
- const alreadyMeasured = measuredKeyRef.current === key;
17838
+ const alreadyMeasured = measuredKeyRef.current === key && measuredExpansionRef.current === shownExpansionSet;
17599
17839
  if (alreadyMeasured && pending2 == null) return;
17600
17840
  measuredKeyRef.current = key;
17841
+ measuredExpansionRef.current = shownExpansionSet;
17601
17842
  const anchor = vsRange.start;
17602
17843
  const anchorBefore = offsetOf(m, anchor);
17603
17844
  let changed = false;
@@ -17605,7 +17846,7 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17605
17846
  for (const tr of el.querySelectorAll("tr[data-row-index]")) {
17606
17847
  const index = Number(tr.dataset.rowIndex);
17607
17848
  if (!Number.isInteger(index)) continue;
17608
- const h = tr.offsetHeight;
17849
+ const h = rowBlockHeight(tr);
17609
17850
  if (h <= 0) continue;
17610
17851
  if (setRowSize(m, index, h)) changed = true;
17611
17852
  }
@@ -17627,6 +17868,54 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17627
17868
  if (delta !== 0 && el.scrollTop > 0) el.scrollTop += delta;
17628
17869
  handleScrollRef.current();
17629
17870
  });
17871
+ const expansionMotionRef = react.useRef(null);
17872
+ const prevExpansionRef = react.useRef(null);
17873
+ useIsomorphicLayoutEffect(() => {
17874
+ if (!expansionOn) return;
17875
+ const prev = prevExpansionRef.current;
17876
+ prevExpansionRef.current = { rows, keys: expandedSet };
17877
+ const running = expansionMotionRef.current;
17878
+ if (running?.done) {
17879
+ running.cancel();
17880
+ expansionMotionRef.current = null;
17881
+ }
17882
+ if (!prev || prev.keys === expandedSet) return;
17883
+ const added = new Set([...expandedSet].filter((key) => !prev.keys.has(key)));
17884
+ const removed = new Set([...prev.keys].filter((key) => !expandedSet.has(key)));
17885
+ if (added.size === 0 && removed.size === 0) return;
17886
+ let from = added.size > 0 ? 0 : 1;
17887
+ if (expansionMotionRef.current) {
17888
+ const current = expansionMotionRef.current;
17889
+ const reversing = current.to === 0 && sameKeys(added, current.keys) || current.to === 1 && sameKeys(removed, current.keys);
17890
+ if (reversing) from = current.openness();
17891
+ current.cancel();
17892
+ expansionMotionRef.current = null;
17893
+ }
17894
+ const scrollEl = scrollRef.current;
17895
+ const keys = added.size > 0 ? added : removed;
17896
+ const keyNames = new Set([...keys].map(String));
17897
+ const canAnimate = scrollEl != null && prev.rows === rows && motionAllowed() && (added.size === 0 || removed.size === 0);
17898
+ const motion = canAnimate ? startRowMotion({
17899
+ scrollEl,
17900
+ isInBlock: (tr) => tr.dataset.expansionRow != null && keyNames.has(tr.dataset.expansionKey ?? ""),
17901
+ blockKeys: keys,
17902
+ from,
17903
+ to: added.size > 0 ? 1 : 0
17904
+ }) : null;
17905
+ if (motion) {
17906
+ expansionMotionRef.current = motion;
17907
+ if (motion.to === 0) {
17908
+ void motion.finished.then((completed) => {
17909
+ if (!completed || expansionMotionRef.current !== motion) return;
17910
+ motion.done = true;
17911
+ setClosingKeys(NO_KEYS);
17912
+ });
17913
+ }
17914
+ } else if (closingKeys.size > 0) {
17915
+ setClosingKeys(NO_KEYS);
17916
+ }
17917
+ }, [expandedSet, closingKeys]);
17918
+ react.useEffect(() => () => expansionMotionRef.current?.cancel(), []);
17630
17919
  react.useEffect(() => {
17631
17920
  if (!virtualScroll) return;
17632
17921
  cbRef.current.onVirtualUpdate?.({ from: vsRange.start, to: vsRange.end });
@@ -17683,7 +17972,7 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
17683
17972
  if (virtualScroll && m != null) return indexAtOffset(m, el.scrollTop);
17684
17973
  const top = el.scrollTop;
17685
17974
  for (const tr of el.querySelectorAll("tr[data-row-index]")) {
17686
- if (tr.offsetTop + tr.offsetHeight > top) return Number(tr.dataset.rowIndex) || 0;
17975
+ if (tr.offsetTop + rowBlockHeight(tr) > top) return Number(tr.dataset.rowIndex) || 0;
17687
17976
  }
17688
17977
  return 0;
17689
17978
  },
@@ -18011,7 +18300,8 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
18011
18300
  {
18012
18301
  value: headerCheck,
18013
18302
  disabled: !hasSelectableRow,
18014
- onValueChange: toggleAll
18303
+ onValueChange: toggleAll,
18304
+ ariaLabel: selectAllLabel
18015
18305
  }
18016
18306
  ) })
18017
18307
  }
@@ -18057,7 +18347,8 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
18057
18347
  {
18058
18348
  value: headerCheck,
18059
18349
  disabled: !hasSelectableRow,
18060
- onValueChange: toggleAll
18350
+ onValueChange: toggleAll,
18351
+ ariaLabel: selectAllLabel
18061
18352
  }
18062
18353
  ) }),
18063
18354
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -18257,112 +18548,168 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
18257
18548
  const rowLocked = (selectable || dragSelectable) && !canSelectRow(row);
18258
18549
  const rowBg = "bg-white";
18259
18550
  const rowHover = hoverable && !rowLocked;
18260
- return /* @__PURE__ */ jsxRuntime.jsxs(
18261
- "tr",
18262
- {
18263
- "data-row-index": rowIndex,
18264
- onClick: () => onRowClick?.(row),
18265
- onDoubleClick: onRowDoubleClick ? (event) => {
18266
- const target = event.target;
18267
- if (target.closest(ROW_INTERACTIVE_SELECTOR)) return;
18268
- onRowDoubleClick(row);
18269
- } : void 0,
18270
- onMouseDown: dragSelectOn ? (event) => startDragSelect(rowIndex, event) : void 0,
18271
- onMouseEnter: dragSelectOn ? () => extendDragSelect(rowIndex) : void 0,
18272
- onContextMenu: contextMenuOn ? (event) => openContextMenu(row, event) : void 0,
18273
- className: cn(
18274
- "group/table-row",
18275
- rowHover && "hover:bg-[var(--color-grey-05)]",
18276
- (onRowClick || onRowDoubleClick) && "cursor-pointer",
18277
- dragSelectOn && "select-none",
18278
- rowClassName?.(row)
18279
- ),
18280
- style: { height: virtualScroll ? effectiveRowHeight : rowH },
18281
- children: [
18282
- selectable && /* @__PURE__ */ jsxRuntime.jsx(
18283
- "td",
18284
- {
18285
- className: cn(
18286
- // 원본 .td--selected: 48px 고정폭, 항상 left sticky, 체크박스 중앙정렬
18287
- "sticky left-0 z-[2] border-b border-solid border-[color:var(--cmp-table-border-color)] align-middle",
18551
+ const expansionShown = expansionOn && shownExpansionSet.has(row[rowKey]);
18552
+ return /* @__PURE__ */ jsxRuntime.jsxs(react.Fragment, { children: [
18553
+ /* @__PURE__ */ jsxRuntime.jsxs(
18554
+ "tr",
18555
+ {
18556
+ "data-row-index": rowIndex,
18557
+ onClick: onRowClick ? (event) => {
18558
+ const target = event.target;
18559
+ if (target.closest(ROW_INTERACTIVE_SELECTOR)) return;
18560
+ onRowClick(row);
18561
+ } : void 0,
18562
+ onDoubleClick: onRowDoubleClick ? (event) => {
18563
+ const target = event.target;
18564
+ if (target.closest(ROW_INTERACTIVE_SELECTOR)) return;
18565
+ onRowDoubleClick(row);
18566
+ } : void 0,
18567
+ onMouseDown: dragSelectOn ? (event) => startDragSelect(rowIndex, event) : void 0,
18568
+ onMouseEnter: dragSelectOn ? () => extendDragSelect(rowIndex) : void 0,
18569
+ onContextMenu: contextMenuOn ? (event) => openContextMenu(row, event) : void 0,
18570
+ className: cn(
18571
+ "group/table-row",
18572
+ rowHover && "hover:bg-[var(--color-grey-05)]",
18573
+ (onRowClick || onRowDoubleClick) && "cursor-pointer",
18574
+ dragSelectOn && "select-none",
18575
+ rowClassName?.(row)
18576
+ ),
18577
+ style: { height: virtualScroll ? effectiveRowHeight : rowH },
18578
+ children: [
18579
+ selectable && /* @__PURE__ */ jsxRuntime.jsx(
18580
+ "td",
18581
+ {
18582
+ className: cn(
18583
+ // 원본 .td--selected: 48px 고정폭, 항상 left sticky, 체크박스 중앙정렬
18584
+ "sticky left-0 z-[2] border-b border-solid border-[color:var(--cmp-table-border-color)] align-middle",
18585
+ rowBg,
18586
+ rowHover && "group-hover/table-row:bg-[var(--color-grey-05)]",
18587
+ stickyLeft === 0 && scrolledLeft && "shadow-[5px_0_8px_0_rgba(34,34,34,0.1)]"
18588
+ ),
18589
+ style: {
18590
+ width: SELECTABLE_COLUMN_WIDTH,
18591
+ minWidth: SELECTABLE_COLUMN_WIDTH,
18592
+ maxWidth: SELECTABLE_COLUMN_WIDTH,
18593
+ // 선택 열도 행의 일부다 — 여기만 희지 않으면 회색 줄이 끊긴다
18594
+ background: rowLocked ? LOCKED_BG : void 0
18595
+ },
18596
+ onClick: (e) => e.stopPropagation(),
18597
+ onDoubleClick: (e) => e.stopPropagation(),
18598
+ onClickCapture: (e) => {
18599
+ shiftPressedRef.current = e.shiftKey;
18600
+ if (e.shiftKey) window.getSelection()?.removeAllRanges();
18601
+ },
18602
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex select-none items-center justify-center", children: /* @__PURE__ */ jsxRuntime.jsx(
18603
+ SCheckbox,
18604
+ {
18605
+ value: isSel || !isRowIndeterminate?.(row) ? isSel : null,
18606
+ disabled: !canSelectRow(row),
18607
+ onValueChange: () => toggleRow(row, rowOffset + ri),
18608
+ ariaLabel: rowSelectLabel?.(row) ?? "\uD589 \uC120\uD0DD"
18609
+ }
18610
+ ) })
18611
+ }
18612
+ ),
18613
+ visibleCols.map((col, i) => {
18614
+ const tdClassName = cn(
18615
+ // 원본 .td: 컬럼 폭에 맞춰 내용 말줄임(overflow hidden + ellipsis) + 행 구분선(border-b)
18616
+ // 셀 배경은 흰색으로 채워 뒤 배경이 비치지 않게 하고, hover 시 행 배경(grey_05)을 따른다.
18617
+ "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)]",
18288
18618
  rowBg,
18289
18619
  rowHover && "group-hover/table-row:bg-[var(--color-grey-05)]",
18290
- stickyLeft === 0 && scrolledLeft && "shadow-[5px_0_8px_0_rgba(34,34,34,0.1)]"
18291
- ),
18292
- style: {
18293
- width: SELECTABLE_COLUMN_WIDTH,
18294
- minWidth: SELECTABLE_COLUMN_WIDTH,
18295
- maxWidth: SELECTABLE_COLUMN_WIDTH,
18296
- // 선택 열도 행의 일부다 — 여기만 희지 않으면 회색 줄이 끊긴다
18297
- background: rowLocked ? LOCKED_BG : void 0
18298
- },
18299
- onClick: (e) => e.stopPropagation(),
18300
- onDoubleClick: (e) => e.stopPropagation(),
18301
- onClickCapture: (e) => {
18302
- shiftPressedRef.current = e.shiftKey;
18303
- if (e.shiftKey) window.getSelection()?.removeAllRanges();
18304
- },
18305
- children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex select-none items-center justify-center", children: /* @__PURE__ */ jsxRuntime.jsx(
18306
- SCheckbox,
18620
+ typeof col.tdClass === "function" ? col.tdClass(row) : col.tdClass
18621
+ );
18622
+ const tdPadX = cellPadXOf(col);
18623
+ const dragEdges = dragSel ? [
18624
+ dragTop && `inset 0 ${DRAG_EDGE_W} 0 0 ${DRAG_EDGE_C}`,
18625
+ dragBottom && `inset 0 ${DRAG_EDGE_NEG_W} 0 0 ${DRAG_EDGE_C}`,
18626
+ i === 0 && `inset ${DRAG_EDGE_W} 0 0 0 ${DRAG_EDGE_C}`,
18627
+ i === visibleCols.length - 1 && `inset ${DRAG_EDGE_NEG_W} 0 0 0 ${DRAG_EDGE_C}`
18628
+ ].filter(Boolean) : [];
18629
+ const shadows = [...dragEdges, stickyShadowValue(i)].filter(Boolean);
18630
+ const tdStyle = {
18631
+ paddingLeft: tdPadX,
18632
+ paddingRight: tdPadX,
18633
+ paddingTop: cellPadY,
18634
+ paddingBottom: cellPadY,
18635
+ textAlign: col.align ?? "left",
18636
+ // 인라인이라 hover 배경보다 세다 — 고른 행은 hover 로 흐려지지 않는다.
18637
+ background: dragSel ? DRAG_BG : rowLocked ? LOCKED_BG : void 0,
18638
+ // 클래스의 기본 글자색을 덮는다. 셀이 자기 색을 정한 내용(SIcon 의
18639
+ // color, STag 등)까지는 닿지 않는다 — 그건 소비 앱 몫이다.
18640
+ color: rowLocked ? LOCKED_CONTENT : void 0,
18641
+ boxShadow: shadows.length > 0 ? shadows.join(", ") : void 0,
18642
+ ...cellStyle(i)
18643
+ };
18644
+ if (col.renderCell) {
18645
+ const cell = col.renderCell({
18646
+ row,
18647
+ value: cellValue(col, row),
18648
+ column: col,
18649
+ colIndex: i,
18650
+ rowIndex: ri,
18651
+ isSelected: isSel,
18652
+ className: tdClassName,
18653
+ style: tdStyle
18654
+ });
18655
+ if (cell == null || cell === false) return null;
18656
+ return /* @__PURE__ */ jsxRuntime.jsx(react.Fragment, { children: cell }, col.name);
18657
+ }
18658
+ const content = col.render ? col.render(row, cellValue(col, row)) : cellValue(col, row);
18659
+ if (i === expansionColIndex) {
18660
+ return /* @__PURE__ */ jsxRuntime.jsx(
18661
+ "td",
18662
+ {
18663
+ className: cn(tdClassName, "cursor-pointer"),
18664
+ style: tdStyle,
18665
+ onClick: (event) => {
18666
+ if (isOwnControl(event.target, event.currentTarget)) return;
18667
+ event.stopPropagation();
18668
+ toggleExpanded(row);
18669
+ },
18670
+ onDoubleClick: (event) => event.stopPropagation(),
18671
+ children: /* @__PURE__ */ jsxRuntime.jsx(ExpansionToggleCell, { expanded: isRowExpanded(row), children: content })
18672
+ },
18673
+ col.name
18674
+ );
18675
+ }
18676
+ return /* @__PURE__ */ jsxRuntime.jsx("td", { className: tdClassName, style: tdStyle, children: content }, col.name);
18677
+ })
18678
+ ]
18679
+ }
18680
+ ),
18681
+ expansionShown && // 상세 영역 — 행이 아니므로 data-row-index 를 달지 않는다 (행 수·선택·실측 대상 밖)
18682
+ /* @__PURE__ */ jsxRuntime.jsx(
18683
+ "tr",
18684
+ {
18685
+ "data-expansion-row": "",
18686
+ "data-expansion-key": String(row[rowKey]),
18687
+ className: expandedSet.has(row[rowKey]) ? void 0 : "pointer-events-none",
18688
+ children: /* @__PURE__ */ jsxRuntime.jsx(
18689
+ "td",
18690
+ {
18691
+ colSpan: totalColSpan,
18692
+ 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)]",
18693
+ children: /* @__PURE__ */ jsxRuntime.jsx(
18694
+ "div",
18307
18695
  {
18308
- value: isSel || !isRowIndeterminate?.(row) ? isSel : null,
18309
- disabled: !canSelectRow(row),
18310
- onValueChange: () => toggleRow(row, rowOffset + ri)
18696
+ className: "sticky left-0 box-border min-w-0",
18697
+ style: {
18698
+ // 가로 스크롤이 있어도 보이는 폭에 머문다. 재기 전(0)에는 표 폭을 따른다
18699
+ width: viewportWidth > 0 ? viewportWidth : void 0,
18700
+ paddingLeft: "var(--cmp-table-body-paddingX-wide)",
18701
+ paddingRight: "var(--cmp-table-body-paddingX-wide)",
18702
+ paddingTop: cellPadY,
18703
+ paddingBottom: cellPadY
18704
+ },
18705
+ children: renderExpansion?.(row)
18311
18706
  }
18312
- ) })
18313
- }
18314
- ),
18315
- visibleCols.map((col, i) => {
18316
- const tdClassName = cn(
18317
- // 원본 .td: 컬럼 폭에 맞춰 내용 말줄임(overflow hidden + ellipsis) + 행 구분선(border-b)
18318
- // 셀 배경은 흰색으로 채워 뒤 배경이 비치지 않게 하고, hover 시 행 배경(grey_05)을 따른다.
18319
- "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)]",
18320
- rowBg,
18321
- rowHover && "group-hover/table-row:bg-[var(--color-grey-05)]",
18322
- typeof col.tdClass === "function" ? col.tdClass(row) : col.tdClass
18323
- );
18324
- const tdPadX = cellPadXOf(col);
18325
- const dragEdges = dragSel ? [
18326
- dragTop && `inset 0 ${DRAG_EDGE_W} 0 0 ${DRAG_EDGE_C}`,
18327
- dragBottom && `inset 0 ${DRAG_EDGE_NEG_W} 0 0 ${DRAG_EDGE_C}`,
18328
- i === 0 && `inset ${DRAG_EDGE_W} 0 0 0 ${DRAG_EDGE_C}`,
18329
- i === visibleCols.length - 1 && `inset ${DRAG_EDGE_NEG_W} 0 0 0 ${DRAG_EDGE_C}`
18330
- ].filter(Boolean) : [];
18331
- const shadows = [...dragEdges, stickyShadowValue(i)].filter(Boolean);
18332
- const tdStyle = {
18333
- paddingLeft: tdPadX,
18334
- paddingRight: tdPadX,
18335
- paddingTop: cellPadY,
18336
- paddingBottom: cellPadY,
18337
- textAlign: col.align ?? "left",
18338
- // 인라인이라 hover 배경보다 세다 — 고른 행은 hover 로 흐려지지 않는다.
18339
- background: dragSel ? DRAG_BG : rowLocked ? LOCKED_BG : void 0,
18340
- // 클래스의 기본 글자색을 덮는다. 셀이 자기 색을 정한 내용(SIcon 의
18341
- // color, STag 등)까지는 닿지 않는다 — 그건 소비 앱 몫이다.
18342
- color: rowLocked ? LOCKED_CONTENT : void 0,
18343
- boxShadow: shadows.length > 0 ? shadows.join(", ") : void 0,
18344
- ...cellStyle(i)
18345
- };
18346
- if (col.renderCell) {
18347
- const cell = col.renderCell({
18348
- row,
18349
- value: cellValue(col, row),
18350
- column: col,
18351
- colIndex: i,
18352
- rowIndex: ri,
18353
- isSelected: isSel,
18354
- className: tdClassName,
18355
- style: tdStyle
18356
- });
18357
- if (cell == null || cell === false) return null;
18358
- return /* @__PURE__ */ jsxRuntime.jsx(react.Fragment, { children: cell }, col.name);
18707
+ )
18359
18708
  }
18360
- return /* @__PURE__ */ jsxRuntime.jsx("td", { className: tdClassName, style: tdStyle, children: col.render ? col.render(row, cellValue(col, row)) : cellValue(col, row) }, col.name);
18361
- })
18362
- ]
18363
- },
18364
- row[rowKey] ?? ri
18365
- );
18709
+ )
18710
+ }
18711
+ )
18712
+ ] }, row[rowKey] ?? ri);
18366
18713
  }),
18367
18714
  virtualScroll && bottomSpacer > 0 && /* @__PURE__ */ jsxRuntime.jsx("tr", { "aria-hidden": true, style: { height: bottomSpacer }, children: /* @__PURE__ */ jsxRuntime.jsx("td", { colSpan: totalColSpan, className: "p-0" }) })
18368
18715
  ] })
@@ -18488,6 +18835,26 @@ var STableImpl = /* @__PURE__ */ react.forwardRef(function STable({
18488
18835
  );
18489
18836
  });
18490
18837
  var STable2 = STableImpl;
18838
+ function isOwnControl(target, cell) {
18839
+ if (!(target instanceof Element)) return false;
18840
+ if (target.closest("[data-expansion-toggle]")) return false;
18841
+ const control = target.closest(ROW_INTERACTIVE_SELECTOR);
18842
+ return control != null && cell.contains(control);
18843
+ }
18844
+ function ExpansionToggleCell({ expanded, children }) {
18845
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 items-center gap-(--cmp-tree-item-contentGap)", children: [
18846
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "data-expansion-toggle": true, className: "flex shrink-0", children: /* @__PURE__ */ jsxRuntime.jsx(
18847
+ SGhostButton,
18848
+ {
18849
+ icon: expanded ? "caretDown" : "caretRight",
18850
+ size: "xxs",
18851
+ ariaLabel: expanded ? "collapse row" : "expand row",
18852
+ ariaExpanded: expanded
18853
+ }
18854
+ ) }),
18855
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "min-w-0 flex-1 truncate", children })
18856
+ ] });
18857
+ }
18491
18858
  var COUNT_NULL = "var(--cmp-table-bar-count-null)";
18492
18859
  var totalColor = (n) => n > 0 ? "var(--cmp-table-bar-count-total)" : COUNT_NULL;
18493
18860
  var selectedColor = (n) => n > 0 ? "var(--color-brilliantblue-75)" : COUNT_NULL;
@@ -18556,9 +18923,7 @@ var STableBar = /* @__PURE__ */ react.forwardRef(function STableBar2({ title, to
18556
18923
  }
18557
18924
  );
18558
18925
  });
18559
- var NO_KEYS = /* @__PURE__ */ new Set();
18560
- var EXPAND_MOTION = { duration: 220, easing: "ease-out" };
18561
- var COLLAPSE_MOTION = { duration: 140, easing: "ease-in" };
18926
+ var NO_KEYS2 = /* @__PURE__ */ new Set();
18562
18927
  function STreeTable({
18563
18928
  rows = [],
18564
18929
  columns = [],
@@ -18611,11 +18976,11 @@ function STreeTable({
18611
18976
  [rows, expandedSet, rowKey, childrenKey]
18612
18977
  );
18613
18978
  const tableRef = react.useRef(null);
18614
- const [closingKeys, setClosingKeys] = react.useState(NO_KEYS);
18979
+ const [closingKeys, setClosingKeys] = react.useState(NO_KEYS2);
18615
18980
  const [prevExpand, setPrevExpand] = react.useState({ rows, expandedSet });
18616
18981
  if (prevExpand.rows !== rows || prevExpand.expandedSet !== expandedSet) {
18617
18982
  setPrevExpand({ rows, expandedSet });
18618
- let nextClosing = NO_KEYS;
18983
+ let nextClosing = NO_KEYS2;
18619
18984
  if (prevExpand.rows === rows && motionAllowed()) {
18620
18985
  const collapsed = [...prevExpand.expandedSet].filter((key) => !expandedSet.has(key));
18621
18986
  const expanded = [...expandedSet].filter((key) => !prevExpand.expandedSet.has(key));
@@ -18664,11 +19029,15 @@ function STreeTable({
18664
19029
  motionRef.current = null;
18665
19030
  }
18666
19031
  const scrollEl = tableRef.current?.getScrollElement();
19032
+ const rowKeys = renderedRows.map(keyOf);
18667
19033
  const keys = added.size > 0 ? added : removed;
18668
19034
  const canAnimate = scrollEl != null && motionAllowed() && (added.size === 0 || removed.size === 0);
18669
19035
  const motion = canAnimate ? startRowMotion({
18670
19036
  scrollEl,
18671
- rowKeys: renderedRows.map(keyOf),
19037
+ isInBlock: (tr) => {
19038
+ const key = rowKeys[Number(tr.dataset.rowIndex)];
19039
+ return key !== void 0 && keys.has(key);
19040
+ },
18672
19041
  blockKeys: keys,
18673
19042
  from,
18674
19043
  to: added.size > 0 ? 1 : 0
@@ -18679,11 +19048,11 @@ function STreeTable({
18679
19048
  void motion.finished.then((completed) => {
18680
19049
  if (!completed || motionRef.current !== motion) return;
18681
19050
  motion.done = true;
18682
- setClosingKeys(NO_KEYS);
19051
+ setClosingKeys(NO_KEYS2);
18683
19052
  });
18684
19053
  }
18685
19054
  } else if (closingKeys.size > 0) {
18686
- setClosingKeys(NO_KEYS);
19055
+ setClosingKeys(NO_KEYS2);
18687
19056
  }
18688
19057
  }, [renderedRows]);
18689
19058
  react.useEffect(() => () => motionRef.current?.cancel(), []);
@@ -18756,7 +19125,7 @@ function STreeTable({
18756
19125
  className: cn(className, hasChildren && "cursor-pointer"),
18757
19126
  style,
18758
19127
  onClick: hasChildren ? (event) => {
18759
- if (isOwnControl(event.target, event.currentTarget)) return;
19128
+ if (isOwnControl2(event.target, event.currentTarget)) return;
18760
19129
  event.stopPropagation();
18761
19130
  toggleExpanded(row);
18762
19131
  } : void 0,
@@ -18811,96 +19180,8 @@ function flattenRows(nodes, isOpen, childrenOf) {
18811
19180
  walk(nodes);
18812
19181
  return out;
18813
19182
  }
18814
- function sameKeys(a, b) {
18815
- return a.size === b.size && [...a].every((key) => b.has(key));
18816
- }
18817
- function motionAllowed() {
18818
- if (typeof window === "undefined" || typeof Element.prototype.animate !== "function")
18819
- return false;
18820
- return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
18821
- }
18822
- function startRowMotion({
18823
- scrollEl,
18824
- rowKeys,
18825
- blockKeys,
18826
- from,
18827
- to
18828
- }) {
18829
- const spec = to === 1 ? EXPAND_MOTION : COLLAPSE_MOTION;
18830
- const duration = spec.duration * Math.abs(to - from);
18831
- if (duration < 1) return null;
18832
- const trs = [...scrollEl.querySelectorAll("tr[data-row-index]")].map((tr) => {
18833
- const key = rowKeys[Number(tr.dataset.rowIndex)];
18834
- return {
18835
- tr,
18836
- inBlock: key !== void 0 && blockKeys.has(key),
18837
- height: tr.getBoundingClientRect().height
18838
- };
18839
- });
18840
- const viewBottom = scrollEl.scrollTop + scrollEl.clientHeight;
18841
- const openAt = (offset) => from + (to - from) * offset;
18842
- const translate = (shift2, s) => `translateY(${-shift2 * (1 - s)}px)`;
18843
- const timing = {
18844
- duration,
18845
- easing: spec.easing,
18846
- // 접힘은 남겨 둔 행이 빠질 때까지 끝 위치를 붙잡는다 — 놓으면 그 사이 한 프레임 제자리로 튄다
18847
- fill: to === 0 ? "forwards" : "none"
18848
- };
18849
- const animations = [];
18850
- let shift = 0;
18851
- let blockHeight = 0;
18852
- let y = 0;
18853
- trs.forEach(({ tr, inBlock, height }, i) => {
18854
- if (inBlock && !trs[i - 1]?.inBlock) {
18855
- blockHeight = 0;
18856
- for (let j = i; j < trs.length && trs[j].inBlock; j++) blockHeight += trs[j].height;
18857
- shift += blockHeight;
18858
- y = 0;
18859
- }
18860
- if (shift === 0 || tr.offsetTop - shift > viewBottom) return;
18861
- if (!inBlock) {
18862
- animations.push(
18863
- tr.animate(
18864
- [{ transform: translate(shift, from) }, { transform: translate(shift, to) }],
18865
- timing
18866
- )
18867
- );
18868
- return;
18869
- }
18870
- const rowY = y;
18871
- const clipAt = (s) => Math.min(height, Math.max(0, blockHeight * (1 - s) - rowY));
18872
- const bends = [1 - rowY / blockHeight, 1 - (rowY + height) / blockHeight].map((s) => (s - from) / (to - from)).filter((offset) => offset > 0 && offset < 1);
18873
- const offsets = [0, ...bends, 1].sort((a, b) => a - b);
18874
- animations.push(
18875
- tr.animate(
18876
- offsets.map((offset) => ({
18877
- offset,
18878
- transform: translate(shift, openAt(offset)),
18879
- clipPath: `inset(${clipAt(openAt(offset))}px 0 0 0)`
18880
- })),
18881
- timing
18882
- )
18883
- );
18884
- y += height;
18885
- });
18886
- if (animations.length === 0) return null;
18887
- return {
18888
- keys: blockKeys,
18889
- to,
18890
- done: false,
18891
- finished: Promise.all(animations.map((animation) => animation.finished)).then(
18892
- () => true,
18893
- () => false
18894
- ),
18895
- openness: () => {
18896
- const progress = animations[0].effect?.getComputedTiming().progress;
18897
- return progress == null ? to : openAt(progress);
18898
- },
18899
- cancel: () => animations.forEach((animation) => animation.cancel())
18900
- };
18901
- }
18902
19183
  var OWN_CONTROL_SELECTOR = 'a, button, input, select, textarea, label, [role="button"], [role="checkbox"], [contenteditable="true"]';
18903
- function isOwnControl(target, cell) {
19184
+ function isOwnControl2(target, cell) {
18904
19185
  if (!(target instanceof Element)) return false;
18905
19186
  if (target.closest("[data-tree-toggle]")) return false;
18906
19187
  const control = target.closest(OWN_CONTROL_SELECTOR);