sellmate-design-system-react 9.0.0-beta.42 → 9.0.0-beta.44

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/AGENTS.md CHANGED
@@ -476,7 +476,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
476
476
  | 여러 줄 텍스트를 받는다 | `STextarea` | §3-7-1 |
477
477
  | 제목·굵게·목록·색 같은 **서식이 남아야 하는** 글을 받는다 | `SEditor` | §3-7-1 |
478
478
  | 목록·결과를 검색어로 좁힌다 | `SSearchInput` | §3-7-1 |
479
- | 숫자(수량·금액)를 받는다 | `SNumberInput` | |
479
+ | 숫자(수량·금액)를 받는다 | `SNumberInput` | §3-7-13 |
480
480
  | 바코드를 스캔해 받는다 | `SBarcodeInput` | |
481
481
  | 목록에서 하나 고르게 한다 | `SSelect` | §3-7-2 |
482
482
  | 선택지를 항상 펼쳐 두고 하나 고르게 한다 | `SRadioGroup` | §3-7-2 |
@@ -1462,6 +1462,23 @@ const [from, setFrom] = useState<string | null>(null);
1462
1462
  ❌ <SListItem title="일반 문의" selected /> {/* clickable 없으면 선택 표시가 안 나온다 */}
1463
1463
  ```
1464
1464
 
1465
+ ##### 행 안에 컨트롤을 둘 때
1466
+
1467
+ `renderItem` 이 앱 몫이라 행 안에 입력·버튼이 들어오는 것은 예외가 아니다 — 순서를 정하는 목록은 대개 「몇 번째로 보낼지」를 숫자로도 받는다. 그냥 두면 된다.
1468
+
1469
+ - **행 선택은 옵트인이다.** `selectedKey`·`defaultSelectedKey`·`onSelectedKeyChange` 중 하나라도 주면 행이 `role="button"` 이 되어 클릭·Enter·Space 로 선택된다. 하나도 주지 않으면 행은 아무 상호작용도 갖지 않는다. **순서만 바꾸는 목록에 선택 prop 을 습관적으로 붙이지 않는다** — 붙이지 않아야 행 안의 컨트롤이 중첩 인터랙티브가 되지 않고 스크린리더에 그대로 노출된다.
1470
+ - **선택을 함께 쓰더라도 자손 컨트롤의 이벤트를 끊지 않는다.** 입력·버튼·링크에서 난 클릭과 Enter·Space 는 선택으로 새지 않는다 — 행의 제목 텍스트를 눌러 고르는 것만 선택으로 간다. `stopPropagation` 을 직접 넣을 자리가 아니다.
1471
+
1472
+ ```tsx
1473
+ ✅ <SDraggableList items={rows} getKey={r => r.id} {/* 선택을 안 쓰면 선택 prop 도 없다 */}
1474
+ renderItem={(row, _i, { onDragHandleMouseDown }) => (
1475
+ <SDraggableItem title={row.name} onDragHandleMouseDown={onDragHandleMouseDown}
1476
+ trailing={<SNumberInput width={72} value={row.order} onValueChange={…} />} />
1477
+ )} />
1478
+
1479
+ ❌ <div onKeyDown={e => { e.stopPropagation(); … }}> {/* 리스트가 이미 걸러 준다 */}
1480
+ ```
1481
+
1465
1482
  #### 3-7-7. 펼치는 셋 — SExpansionItem vs SExpansionList vs STree
1466
1483
 
1467
1484
  | 판별 | 사용 |
@@ -1569,6 +1586,29 @@ const [selectedId, setSelectedId] = useState<string>();
1569
1586
  ❌ {loading ? <SCircleProgress indeterminate /> : <SImage src={url} />} {/* SImage 가 이미 한다 */}
1570
1587
  ```
1571
1588
 
1589
+ #### 3-7-13. 숫자 — SNumberInput 의 min·max 는 검증 경계다
1590
+
1591
+ `min`·`max` 는 `<input type="number">` 의 그것과 같다 — **값을 고쳐 쓰지 않는 검증 경계**다. 범위를 벗어나면 blur·제출 시 에러 상태가 서고, 사용자가 친 값은 그대로 남아 앱의 범위 가드에 도달한다.
1592
+
1593
+ - **문구를 앱이 대지 않아도 된다.** `rules` 도 `SForm` 도 없는 화면에서 `min`·`max` 만 주면 DS 가 「1~99 사이로 입력해 주세요.」 같은 기본 안내를 자동으로 붙인다. 범위 이탈이 아무 표시 없이 지나가는 경로가 없다.
1594
+ - **화면의 말로 바꾸려면 `rules` 를 준다.** 「우선순위는 1부터」처럼 그 화면에서만 통하는 문구가 있으면 `rules` 가 DS 기본 문구를 이긴다. 앱이 직접 준 `errorMessage` 도 마찬가지다.
1595
+ - **`SForm` 안에서는 범위 이탈이 제출을 막는다.** `rules` 를 따로 걸지 않아도 그렇다 — 네이티브 폼과 같다.
1596
+ - **빈 칸(`null`)은 범위 판정 대상이 아니다.** 필수 입력은 `min` 이 아니라 `rules` 로 막는다.
1597
+ - **경계로 붙여도 사용자가 놀라지 않는 값에서만 `clampOnBlur` 를 켠다.** 켜면 blur 시 값이 경계로 **말없이 바뀌고** `onValueChange` 로 되쏘아진다 — 그래서 앱의 범위 가드에는 범위 밖 값이 도달하지 않는다. 재고 조정 수량처럼 보정이 자연스러운 자리에만 쓰고, 업무 규칙(최소 주문 수량·우선순위 시작값)에는 쓰지 않는다.
1598
+ - 스테퍼(`showButton`)와 위/아래 화살표는 `clampOnBlur` 와 무관하게 언제나 `min`·`max` 를 한계로 삼는다.
1599
+
1600
+ ```tsx
1601
+ ✅ <SNumberInput label="우선순위" min={1} max={99} value={v} onValueChange={setV} />
1602
+ {/* 0 을 넣고 blur 하면 0 이 남고 에러가 선다 — 저장 가드의 v < 1 이 그대로 걸린다 */}
1603
+
1604
+ ✅ <SNumberInput label="우선순위" min={1} max={99} value={v} onValueChange={setV}
1605
+ rules={[v => (typeof v === 'number' && (v < 1 || v > 99) ? '우선순위는 1부터 99까지입니다.' : true)]} />
1606
+ {/* 문구만 화면의 말로 바꾼다 */}
1607
+
1608
+ ❌ <SNumberInput label="우선순위" min={1} clampOnBlur value={v} onValueChange={setV} />
1609
+ {/* 0 이 1 로 바뀐 뒤 도착하므로 저장 직전의 v < 1 가드는 영원히 걸리지 않는다 */}
1610
+ ```
1611
+
1572
1612
  ---
1573
1613
 
1574
1614
  ## 4. 페이지 레시피 — 표준 골격
@@ -25,8 +25,8 @@
25
25
  | `items` | `T[]` | — | 현재 순서대로 렌더링할 아이템 목록 |
26
26
  | `getKey` | `(item: T, index: number) => string` | — | 아이템을 식별할 안정적인 key |
27
27
  | `renderItem` | `(item: T, index: number, state: SDraggableListRenderState) => ReactNode` | — | 아이템 렌더링 함수 |
28
- | `selectedKey?` | `string` | — | 외부에서 제어하는 selected 아이템 key |
29
- | `defaultSelectedKey?` | `string` | — | 초기 selected 아이템 key |
28
+ | `selectedKey?` | `string` | — | 외부에서 제어하는 selected 아이템 key. 이 셋(`selectedKey`·`defaultSelectedKey`·`onSelectedKeyChange`) 중 하나라도 주면 **행 선택이 켜진다** — 행이 `role="button"` 이 되어 클릭·Enter·Space 로 선택된다. 아무것도 주지 않으면 행은 아무 상호작용도 갖지 않는다. |
29
+ | `defaultSelectedKey?` | `string` | — | 초기 selected 아이템 key. 주면 행 선택이 켜진다 (`selectedKey` 참조) |
30
30
  | `getDisabled?` | `(item: T, index: number) => boolean` | — | disabled 아이템은 선택 및 드래그에서 제외 |
31
31
  | `getDepth?` | `(item: T, index: number) => number` | — | 아이템의 중첩 단계. 기본값은 1 |
32
32
  | `setDepth?` | `(item: T, depth: number) => T` | — | depth 변경이 필요한 드롭에서 다음 아이템을 만드는 함수 |
@@ -40,7 +40,7 @@
40
40
  | Event | Type | Description |
41
41
  |-------|------|-------------|
42
42
  | `onChange` | `(items: T[]) => void` | 단독 리스트에서 드래그가 끝난 뒤 변경된 순서 |
43
- | `onSelectedKeyChange` | `(key: string) => void` | selected 아이템이 변경될 때 호출 |
43
+ | `onSelectedKeyChange` | `(key: string) => void` | selected 아이템이 변경될 때 호출. 주면 행 선택이 켜진다 (`selectedKey` 참조) |
44
44
 
45
45
  ### SDraggableProvider
46
46
 
@@ -16,11 +16,15 @@ export interface SDraggableListProps<T> extends Omit<SListProps, 'children' | 'o
16
16
  renderItem: (item: T, index: number, state: SDraggableListRenderState) => ReactNode;
17
17
  /** 단독 리스트에서 드래그가 끝난 뒤 변경된 순서 */
18
18
  onChange?: (items: T[]) => void;
19
- /** 외부에서 제어하는 selected 아이템 key */
19
+ /**
20
+ * 외부에서 제어하는 selected 아이템 key.
21
+ * 이 셋(`selectedKey`·`defaultSelectedKey`·`onSelectedKeyChange`) 중 하나라도 주면 **행 선택이 켜진다** —
22
+ * 행이 `role="button"` 이 되어 클릭·Enter·Space 로 선택된다. 아무것도 주지 않으면 행은 아무 상호작용도 갖지 않는다.
23
+ */
20
24
  selectedKey?: string;
21
- /** 초기 selected 아이템 key */
25
+ /** 초기 selected 아이템 key. 주면 행 선택이 켜진다 (`selectedKey` 참조) */
22
26
  defaultSelectedKey?: string;
23
- /** selected 아이템이 변경될 때 호출 */
27
+ /** selected 아이템이 변경될 때 호출. 주면 행 선택이 켜진다 (`selectedKey` 참조) */
24
28
  onSelectedKeyChange?: (key: string) => void;
25
29
  /** disabled 아이템은 선택 및 드래그에서 제외 */
26
30
  getDisabled?: (item: T, index: number) => boolean;
@@ -13,7 +13,7 @@
13
13
  | `message?` | `SGuideMessage` | `''` | 팝업 메시지 (배열이면 리스트, 중첩 배열이면 depth 표현) |
14
14
  | `url?` | `string` | `''` | notion 타입 클릭 시 이동 URL |
15
15
  | `popupTitle?` | `string` | `''` | 팝업 제목 (없으면 type 기본값) |
16
- | `popupWidth?` | `number` | | 팝업 너비 (px, 기본 426). 창보다 넓게 줘도폭까지만 벌어진다. |
16
+ | `popupWidth?` | `number \| 'auto'` | `'auto'` | 팝업 너비. 기본 `'auto'` 는 내용 폭에 맞춘다 — 가장 긴 줄이 곧 팝업 폭이다. 숫자(px)를 주면 폭으로 고정한다. 어느 쪽이든 좌우에 여백을 남기는 선까지만 벌어진다. |
17
17
  | `className?` | `string` | — | |
18
18
  | `style?` | `CSSProperties` | — | |
19
19
 
@@ -13,8 +13,11 @@ export interface SGuideProps {
13
13
  url?: string;
14
14
  /** 팝업 제목 (없으면 type 기본값) */
15
15
  popupTitle?: string;
16
- /** 팝업 너비 (px, 기본 426). 창보다 넓게 줘도 창 폭까지만 벌어진다. */
17
- popupWidth?: number;
16
+ /**
17
+ * 팝업 너비. 기본 `'auto'` 는 내용 폭에 맞춘다 — 가장 긴 줄이 곧 팝업 폭이다.
18
+ * 숫자(px)를 주면 그 폭으로 고정한다. 어느 쪽이든 창 좌우에 여백을 남기는 선까지만 벌어진다.
19
+ */
20
+ popupWidth?: number | 'auto';
18
21
  className?: string;
19
22
  style?: CSSProperties;
20
23
  }
@@ -10,8 +10,9 @@
10
10
  |------|------|---------|-------------|
11
11
  | `value?` | `string \| number \| null` | — | 입력 값 |
12
12
  | `size?` | `SNumberInputSize` | `'sm'` | 크기 |
13
- | `min?` | `number` | `Number.NEGATIVE_INFINITY` | 최솟값 |
14
- | `max?` | `number` | `Number.POSITIVE_INFINITY` | 최댓값 |
13
+ | `min?` | `number` | `Number.NEGATIVE_INFINITY` | 최솟값 — **검증 경계**다 (`<input type="number">` 의 `min` 과 같다). 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다. 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다. |
14
+ | `max?` | `number` | `Number.POSITIVE_INFINITY` | 최댓값 — **검증 경계**다 (`<input type="number">` 의 `max` 와 같다). 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다. 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다. |
15
+ | `clampOnBlur?` | `boolean` | `false` | blur 시 값을 `[min, max]` 경계로 끌어당길지. 기본 `false`. 기본값에서는 사용자가 친 값이 그대로 보존되고, 범위를 벗어나면 에러 상태로 알린다 — 그래야 소비자의 범위 검증에 실제 입력값이 도달한다. `true` 로 켜면 범위 밖 값이 경계값으로 **말없이 바뀌고** `onValueChange` 로 되쏘아진다 (`min={1}` 인 칸에 `0` 을 넣고 blur 하면 `onValueChange(1)` 이 발화한다). 경계로 붙여도 사용자가 놀라지 않는 값에만 켠다. 켜면 범위 에러는 서지 않는다 — 고쳐 쓴 값은 언제나 범위 안이기 때문이다. |
15
16
  | `step?` | `number` | `1` | 스텝 단위 |
16
17
  | `showButton?` | `boolean` | `false` | 증감 버튼 표시 |
17
18
  | `allowDecimal?` | `boolean` | `false` | 소수점 입력 허용 |
@@ -13,10 +13,30 @@ export interface SNumberInputProps {
13
13
  onValueChange?: (value: number | null) => void;
14
14
  /** 크기 */
15
15
  size?: SNumberInputSize;
16
- /** 최솟값 */
16
+ /**
17
+ * 최솟값 — **검증 경계**다 (`<input type="number">` 의 `min` 과 같다).
18
+ * 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다.
19
+ * 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다.
20
+ */
17
21
  min?: number;
18
- /** 최댓값 */
22
+ /**
23
+ * 최댓값 — **검증 경계**다 (`<input type="number">` 의 `max` 와 같다).
24
+ * 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다.
25
+ * 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다.
26
+ */
19
27
  max?: number;
28
+ /**
29
+ * blur 시 값을 `[min, max]` 경계로 끌어당길지. 기본 `false`.
30
+ *
31
+ * 기본값에서는 사용자가 친 값이 그대로 보존되고, 범위를 벗어나면 에러 상태로 알린다 —
32
+ * 그래야 소비자의 범위 검증에 실제 입력값이 도달한다.
33
+ *
34
+ * `true` 로 켜면 범위 밖 값이 경계값으로 **말없이 바뀌고** `onValueChange` 로 되쏘아진다
35
+ * (`min={1}` 인 칸에 `0` 을 넣고 blur 하면 `onValueChange(1)` 이 발화한다).
36
+ * 경계로 붙여도 사용자가 놀라지 않는 값에만 켠다. 켜면 범위 에러는 서지 않는다 —
37
+ * 고쳐 쓴 값은 언제나 범위 안이기 때문이다.
38
+ */
39
+ clampOnBlur?: boolean;
20
40
  /** 스텝 단위 */
21
41
  step?: number;
22
42
  /** 증감 버튼 표시 */
@@ -25,7 +25,7 @@
25
25
  | `emptySlot?` | `ReactNode` | — | 데이터가 없을 때 body 영역 전체를 대체하는 슬롯. 지정하면 `emptyLabel` 대신 이 콘텐츠가 헤더 아래 영역을 채우며, 버튼 등 인터랙션도 동작한다. |
26
26
  | `loading?` | `boolean` | `false` | |
27
27
  | `dense?` | `boolean` | `false` | 행 높이를 좁게 (세로 여백만 줄인다 — 좌우 패딩은 그대로). **시작 밀도이자 밀도 토글의 스위치다.** 켜면 하단 바 우측에 `좁게 보기` · `넓게 보기` 토글이 붙는다 — 페이지네이션이 없으면 이 바를 토글만 담아 그린다. 누른 뒤의 밀도는 테이블이 내부 상태로 들고 가므로 `onDenseChange` 를 받지 않아도 토글은 동작하고, 넓게 본 뒤에도 토글은 그대로 남는다(붙일지는 이 prop 이 정한다). 이 prop 값이 바뀌면 내부 밀도도 그 값으로 맞춰진다. |
28
- | `hoverable?` | `boolean` | `true` | true면 행에 마우스를 올려도 hover 배경(grey_05)을 표시하지 않는다 |
28
+ | `hoverable?` | `boolean` | `true` | 행에 마우스를 올렸을 hover 배경(grey_05)을 표시할지. 기본 `true` — `false` 면 표시하지 않는다 |
29
29
  | `pagination?` | `STablePagination` | — | 페이지네이션 (있으면 하단 표시) |
30
30
  | `internalPagination?` | `boolean` | `false` | 테이블 내부에서 페이지네이션을 직접 관리 (rows를 내부 슬라이싱) |
31
31
  | `showRowsPerPageSelect?` | `boolean` | `false` | 페이지당 행 수 셀렉트 표시 |
@@ -244,7 +244,7 @@ export interface STableProps {
244
244
  * 사용자가 고른 밀도를 다음 방문까지 기억해 두려는(로컬 저장 등) 페이지만 받으면 된다.
245
245
  */
246
246
  onDenseChange?: (dense: boolean) => void;
247
- /** true면 행에 마우스를 올려도 hover 배경(grey_05)을 표시하지 않는다 */
247
+ /** 행에 마우스를 올렸을 hover 배경(grey_05)을 표시할지. 기본 `true` — `false` 면 표시하지 않는다 */
248
248
  hoverable?: boolean;
249
249
  /** 페이지네이션 (있으면 하단 표시) */
250
250
  pagination?: STablePagination;
package/dist/index.cjs CHANGED
@@ -4293,6 +4293,13 @@ function SDraggableProvider({ children }) {
4293
4293
  function SDraggableGroup({ children, onMove, ...rest }) {
4294
4294
  return /* @__PURE__ */ jsxRuntime.jsx(SDraggableProviderInternal, { defaultGroup: "__legacy", legacyOnMove: onMove, children: /* @__PURE__ */ jsxRuntime.jsx("div", { ...rest, children }) });
4295
4295
  }
4296
+ var INTERACTIVE_DESCENDANT_SELECTOR = 'input,textarea,select,button,a[href],label,[contenteditable="true"],[role="button"],[role="checkbox"],[role="radio"],[role="switch"],[role="tab"],[role="menuitem"]';
4297
+ function isFromInteractiveDescendant(event) {
4298
+ const target = event.target;
4299
+ if (target == null || target === event.currentTarget) return false;
4300
+ const interactive = target.closest?.(INTERACTIVE_DESCENDANT_SELECTOR);
4301
+ return interactive != null && interactive !== event.currentTarget;
4302
+ }
4296
4303
  function reorder(items, fromIndex, toIndex) {
4297
4304
  if (fromIndex === toIndex) return items;
4298
4305
  const nextItems = [...items];
@@ -4376,6 +4383,7 @@ function SDraggableListInner({
4376
4383
  const listRef = react.useRef(null);
4377
4384
  const fromIndexRef = react.useRef(null);
4378
4385
  const currentSelectedKey = selectedKey ?? internalSelectedKey;
4386
+ const selectable = selectedKey !== void 0 || defaultSelectedKey !== void 0 || onSelectedKeyChange != null;
4379
4387
  const usesDepth = getDepth != null;
4380
4388
  const depthEnabled = usesDepth && !gap;
4381
4389
  const groupDragging = dragContext?.dragging;
@@ -4535,20 +4543,28 @@ function SDraggableListInner({
4535
4543
  setInternalSelectedKey(itemKey);
4536
4544
  onSelectedKeyChange?.(itemKey);
4537
4545
  };
4546
+ const handleItemClick = (event) => {
4547
+ if (isFromInteractiveDescendant(event)) return;
4548
+ selectItem();
4549
+ };
4538
4550
  const handleItemKeyDown = (event) => {
4551
+ if (event.target !== event.currentTarget) return;
4539
4552
  if (event.key !== "Enter" && event.key !== " ") return;
4540
4553
  event.preventDefault();
4541
4554
  selectItem();
4542
4555
  };
4556
+ const selectionProps = selectable ? {
4557
+ role: "button",
4558
+ tabIndex: disabled ? -1 : 0,
4559
+ onClick: handleItemClick,
4560
+ onKeyDown: handleItemKeyDown
4561
+ } : {};
4543
4562
  return /* @__PURE__ */ jsxRuntime.jsxs(
4544
4563
  "div",
4545
4564
  {
4546
4565
  "data-draggable-list-item": true,
4547
- role: "button",
4548
- tabIndex: disabled ? -1 : 0,
4549
4566
  "aria-disabled": disabled || void 0,
4550
- onClick: selectItem,
4551
- onKeyDown: handleItemKeyDown,
4567
+ ...selectionProps,
4552
4568
  className: "relative",
4553
4569
  children: [
4554
4570
  showBefore && /* @__PURE__ */ jsxRuntime.jsx(
@@ -7879,6 +7895,8 @@ var SPopup = /* @__PURE__ */ react.forwardRef(function SPopup2({
7879
7895
  });
7880
7896
  var DEFAULT_LABEL = { tip: "\uD65C\uC6A9 TIP", notion: "\uC0AC\uC6A9\uBC95 \uC548\uB0B4" };
7881
7897
  var ICON = { tip: "helpOutline", notion: "notion" };
7898
+ var VIEWPORT_GUTTER = 24;
7899
+ var CLOSE_INSET = 12;
7882
7900
  function flattenMessages(message, depth = 0) {
7883
7901
  if (Array.isArray(message)) return message.flatMap((m) => flattenMessages(m, depth + 1));
7884
7902
  return message !== "" ? [{ text: message, depth }] : [];
@@ -7889,13 +7907,14 @@ function SGuide({
7889
7907
  message = "",
7890
7908
  url = "",
7891
7909
  popupTitle = "",
7892
- popupWidth,
7910
+ popupWidth = "auto",
7893
7911
  className,
7894
7912
  style
7895
7913
  }) {
7896
7914
  const [open, setOpen] = react.useState(false);
7897
7915
  const portalContainer = usePortalContainer();
7898
7916
  const isNotion = type === "notion";
7917
+ const isAutoWidth = popupWidth === "auto";
7899
7918
  const defaultLabel = DEFAULT_LABEL[type];
7900
7919
  const iconName = ICON[type];
7901
7920
  const messages = flattenMessages(message);
@@ -7935,30 +7954,45 @@ function SGuide({
7935
7954
  side: "bottom",
7936
7955
  align: "start",
7937
7956
  sideOffset: 4,
7957
+ collisionPadding: VIEWPORT_GUTTER,
7938
7958
  className: cn(
7939
7959
  "relative z-50 rounded-[var(--cmp-guide-contents-radius)] bg-[var(--color-white)]",
7940
- // popupWidth(기본 426px)가 창보다 넓으면 창 폭에 맞춘다 — max-width 가 width 를 이긴다.
7941
- "max-w-[var(--radix-popover-content-available-width)]",
7942
7960
  FLOATING_SLIDE_ANIM
7943
7961
  ),
7944
7962
  style: {
7945
- width: popupWidth != null ? popupWidth : 426,
7963
+ width: isAutoWidth ? "max-content" : popupWidth,
7964
+ // 유일한 폭 상한. auto 든 고정 폭이든 창 좌우 여백을 먹고 들어가지 않는다.
7965
+ // 트리거 위치가 아니라 창 기준이라, 오른쪽 끝 트리거에서도 폭이 깎이지 않는다
7966
+ // (넘치면 Radix 가 왼쪽으로 민다). 100vw 는 세로 스크롤바를 포함하지만
7967
+ // 여백이 그보다 넉넉해 화면 밖으로 나가지 않는다.
7968
+ maxWidth: `calc(100vw - ${VIEWPORT_GUTTER * 2}px)`,
7946
7969
  padding: "var(--cmp-guide-contents-paddingY) var(--cmp-guide-contents-paddingX)",
7947
7970
  boxShadow: "4px 4px 24px 4px rgba(0,0,0,0.1)"
7948
7971
  },
7949
7972
  children: [
7950
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute right-[12px] top-[12px]", children: /* @__PURE__ */ jsxRuntime.jsx(SGhostButton, { icon: "close", size: "sm", ariaLabel: "close", onClick: () => setOpen(false) }) }),
7951
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-[var(--cmp-guide-contents-title-gap)]", children: [
7952
- /* @__PURE__ */ jsxRuntime.jsx(
7953
- SIcon,
7954
- {
7955
- name: iconName,
7956
- size: 24,
7957
- color: isNotion ? "var(--sys-color-fg-primary)" : "var(--cmp-guide-contents-icon)"
7958
- }
7959
- ),
7960
- /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "m-0 text-[16px] font-bold leading-[26px] text-[color:var(--cmp-guide-contents-typography-color)]", children: popupTitle || defaultLabel })
7961
- ] }),
7973
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute", style: { top: CLOSE_INSET, right: CLOSE_INSET }, children: /* @__PURE__ */ jsxRuntime.jsx(SGhostButton, { icon: "close", size: "sm", ariaLabel: "close", onClick: () => setOpen(false) }) }),
7974
+ /* @__PURE__ */ jsxRuntime.jsxs(
7975
+ "div",
7976
+ {
7977
+ className: "flex items-center gap-[var(--cmp-guide-contents-title-gap)]",
7978
+ style: {
7979
+ // 닫기 버튼은 absolute 라 폭 계산에 안 들어간다. 폭이 내용에 붙는 auto 에서 제목이
7980
+ // 버튼 밑으로 들어가지 않도록, 버튼이 콘텐츠 영역을 침범하는 만큼을 미리 비운다.
7981
+ paddingRight: `max(0px, calc(var(--cmp-ghostButton-sm-size) + ${CLOSE_INSET}px + var(--cmp-guide-contents-title-gap) - var(--cmp-guide-contents-paddingX)))`
7982
+ },
7983
+ children: [
7984
+ /* @__PURE__ */ jsxRuntime.jsx(
7985
+ SIcon,
7986
+ {
7987
+ name: iconName,
7988
+ size: 24,
7989
+ color: isNotion ? "var(--sys-color-fg-primary)" : "var(--cmp-guide-contents-icon)"
7990
+ }
7991
+ ),
7992
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "m-0 break-keep text-[16px] font-bold leading-[26px] text-[color:var(--cmp-guide-contents-typography-color)]", children: popupTitle || defaultLabel })
7993
+ ]
7994
+ }
7995
+ ),
7962
7996
  /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "m-0 mt-[var(--cmp-guide-contents-gap)] flex w-full list-none flex-col gap-[var(--cmp-guide-contents-body-gap)] p-0 text-[12px] leading-[20px] text-[color:var(--cmp-guide-contents-typography-color)]", children: messages.map((m, i) => /* @__PURE__ */ jsxRuntime.jsx(
7963
7997
  "li",
7964
7998
  {
@@ -7969,7 +8003,7 @@ function SGuide({
7969
8003
  children: /* @__PURE__ */ jsxRuntime.jsx(
7970
8004
  "p",
7971
8005
  {
7972
- className: "m-0 w-full min-w-0 break-words",
8006
+ className: "m-0 w-full min-w-0 break-keep break-words text-pretty",
7973
8007
  dangerouslySetInnerHTML: { __html: sanitizeInlineHtml(m.text) }
7974
8008
  }
7975
8009
  )
@@ -14666,6 +14700,16 @@ var formatInputWithCommas = (value) => {
14666
14700
  const formattedInt = addCommas(intPart === "" ? "0" : intPart);
14667
14701
  return `${isNeg ? "-" : ""}${formattedInt}${hasDecimal ? `.${decPart}` : ""}`;
14668
14702
  };
14703
+ var getRangeError = (value, min, max) => {
14704
+ if (value === null) return "";
14705
+ const belowMin = value < min;
14706
+ const aboveMax = value > max;
14707
+ if (!belowMin && !aboveMax) return "";
14708
+ if (Number.isFinite(min) && Number.isFinite(max)) {
14709
+ return `${formatWithCommas(min)}~${formatWithCommas(max)} \uC0AC\uC774\uB85C \uC785\uB825\uD574 \uC8FC\uC138\uC694.`;
14710
+ }
14711
+ return belowMin ? `${formatWithCommas(min)} \uC774\uC0C1\uC73C\uB85C \uC785\uB825\uD574 \uC8FC\uC138\uC694.` : `${formatWithCommas(max)} \uC774\uD558\uB85C \uC785\uB825\uD574 \uC8FC\uC138\uC694.`;
14712
+ };
14669
14713
  var getDecimalLimit = (allowDecimal, maxDecimalLength) => {
14670
14714
  if (!allowDecimal || maxDecimalLength === void 0 || !Number.isFinite(maxDecimalLength)) {
14671
14715
  return void 0;
@@ -14696,6 +14740,7 @@ var SNumberInput = /* @__PURE__ */ react.forwardRef(function SNumberInput2({
14696
14740
  min = Number.NEGATIVE_INFINITY,
14697
14741
  max = Number.POSITIVE_INFINITY,
14698
14742
  step = 1,
14743
+ clampOnBlur = false,
14699
14744
  showButton = false,
14700
14745
  allowDecimal = false,
14701
14746
  maxDecimalLength,
@@ -14735,6 +14780,7 @@ var SNumberInput = /* @__PURE__ */ react.forwardRef(function SNumberInput2({
14735
14780
  const numValue = applyDecimalLimit(toNumber(value), decimalLimit);
14736
14781
  const [display, setDisplay] = react.useState(() => formatWithCommas(numValue));
14737
14782
  const [ruleError, setRuleError] = react.useState("");
14783
+ const [rangeError, setRangeError] = react.useState("");
14738
14784
  const innerRef = react.useRef(null);
14739
14785
  const valueTooltip = useTruncatedValueTooltip({ enabled: readOnly && !disabled, text: display });
14740
14786
  const setRefs2 = (el) => {
@@ -14752,12 +14798,19 @@ var SNumberInput = /* @__PURE__ */ react.forwardRef(function SNumberInput2({
14752
14798
  validate: () => {
14753
14799
  const msg = runRules(numValue, rules);
14754
14800
  setRuleError(msg);
14755
- return msg === "";
14801
+ const range = clampOnBlur ? "" : getRangeError(numValue, min, max);
14802
+ setRangeError(range);
14803
+ return msg === "" && range === "";
14756
14804
  },
14757
- resetValidation: () => setRuleError("")
14805
+ resetValidation: () => {
14806
+ setRuleError("");
14807
+ setRangeError("");
14808
+ }
14758
14809
  });
14759
- const resolvedError = error || ruleError !== "";
14760
- const resolvedStatus = status ?? (rules && rules.length > 0 ? ruleError !== "" ? "error" : void 0 : void 0);
14810
+ const autoError = ruleError !== "" ? ruleError : rangeError;
14811
+ const resolvedError = error || autoError !== "";
14812
+ const resolvedStatus = status ?? (autoError !== "" ? "error" : void 0);
14813
+ const resolvedErrorMessage = ruleError !== "" ? ruleError : (errorMessage ?? "") !== "" ? errorMessage : rangeError;
14761
14814
  const restoreCaret = (nextDisplay, logicalCaret) => {
14762
14815
  const input = innerRef.current;
14763
14816
  if (logicalCaret === void 0 || !input || document.activeElement !== input) return;
@@ -14889,7 +14942,7 @@ var SNumberInput = /* @__PURE__ */ react.forwardRef(function SNumberInput2({
14889
14942
  status: resolvedStatus,
14890
14943
  focused,
14891
14944
  hovered,
14892
- errorMessage: ruleError !== "" ? ruleError : errorMessage,
14945
+ errorMessage: resolvedErrorMessage,
14893
14946
  width,
14894
14947
  disabled,
14895
14948
  readOnly,
@@ -14935,10 +14988,11 @@ var SNumberInput = /* @__PURE__ */ react.forwardRef(function SNumberInput2({
14935
14988
  onBlur: (e) => {
14936
14989
  const cleaned = display.replace(/,/g, "");
14937
14990
  const n = toNumber(cleaned);
14938
- const clamped = n === null ? null : Math.min(Math.max(n, min), max);
14991
+ const clamped = !clampOnBlur || n === null ? n : Math.min(Math.max(n, min), max);
14939
14992
  if (clamped !== n) emit3(clamped);
14940
14993
  else setDisplay(n === null ? "" : formatInputWithCommas(cleaned));
14941
14994
  if (rules && rules.length > 0) setRuleError(runRules(clamped, rules));
14995
+ setRangeError(clampOnBlur ? "" : getRangeError(clamped, min, max));
14942
14996
  onBlur?.(e);
14943
14997
  },
14944
14998
  className: cn(