sellmate-design-system-react 9.0.0-beta.0 → 9.0.0-beta.2

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
@@ -32,7 +32,7 @@
32
32
  | **표·목록** | `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SListItem` `SExpansionList` `SDraggableList` `SDraggableItem` `STree` |
33
33
  | **레이아웃** | `SLayout` `SGnb` `SPage`(제목 영역은 `header` prop) `SSectionHeaderCard` `SCard` `SDivider` `SSplitter` `SScrollArea` `SExpansionItem` |
34
34
  | **내비게이션** | `STabs` `SPagination` `SStepper` |
35
- | **표시·상태** | `STag` `SBadge` `SIcon` `SCallout` `SGuide` |
35
+ | **표시·상태** | `STag` `SBadge` `SIcon` `SImage` `SCallout` `SGuide` |
36
36
  | **진행·로딩** | `SLinearProgress` `SCircleProgress` `SLoadingContainer` `SLoadingModal` |
37
37
  | **오버레이** | `STooltip` `SPopover` `SPopup` `SDrawer` `SPortal` |
38
38
  | **모달** | `SModal.confirm()` `SModal.create()` + `SActionModal` `SConfirmModal` `SModalOutlet`(앱 루트 1회) |
@@ -138,7 +138,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
138
138
  | --- | --- | --- |
139
139
  | **셸** | 앱 전체 뼈대. 페이지가 바뀌어도 남는다 | `SLayout` `SGnb` `SPage`(제목 영역은 `header` prop) |
140
140
  | **블록** | `SPage` 의 직계 자식. 페이지를 세로로 쌓는 단위 | `SSectionHeaderCard` `SCard` `SForm` `SSplitter` `SScrollArea` `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SExpansionList` `SDraggableList` `STree` `SCallout` `STabs` `SStepper` `SPagination` `SDivider` |
141
- | **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SDropdownButton` `SField` `SInput` `SNumberInput` `STextarea` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SSwitch` `SToggle` `SChipInput` `SBarcodeInput` `SFilePicker` `SDatePicker` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SLinearProgress` `SCircleProgress` |
141
+ | **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SDropdownButton` `SField` `SInput` `SNumberInput` `STextarea` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SSwitch` `SToggle` `SChipInput` `SBarcodeInput` `SFilePicker` `SDatePicker` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SImage` `SLinearProgress` `SCircleProgress` |
142
142
  | **인라인** | 텍스트 흐름·셀·라벨 안에 섞인다. 혼자 블록이 되지 않는다 | `STag` `SBadge` `SIcon` `STextLink` `SChip` |
143
143
  | **레이어** | 문서 흐름 **밖**에 떠서 그려진다. 어느 층에서 띄우든 레이아웃에 영향이 없다 | `SModal` `SActionModal` `SConfirmModal` `SPopup` `SDrawer` `SPopover` `STooltip` `SPortal` `SToast` `SLoadingModal` `SLoadingContainer` `SGuide` |
144
144
 
@@ -442,6 +442,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
442
442
  | 상태·분류를 라벨로 찍는다 | `STag` | §3-1 |
443
443
  | 색 점만으로 상태를 찍는다 | `SBadge` | §3-1 |
444
444
  | 아이콘을 넣는다 | `SIcon` | |
445
+ | 사진·썸네일을 보여준다 (로딩·실패 상태 포함) | `SImage` | §3-7-12 |
445
446
  | 문장 안에서 다른 화면으로 보낸다 | `STextLink` | §3-5-6 |
446
447
 
447
448
  #### C. 동작을 실행시킨다
@@ -1085,6 +1086,23 @@ const columns: STableColumn[] = [
1085
1086
  - 검색 실행 시점이 다르다 — `SKeyValueTable` 필터는 앱이 검색 버튼을 직접 놓지만, `SChipFilter` 는 편집 팝오버가 닫히거나 "검색" 을 누를 때 `onSearch` 가 값 맵과 함께 호출된다. 값이 바뀌지 않았으면 호출되지 않는다.
1086
1087
  - 함께 걸어야 하는 조건(예: 기간 중 하나는 필수)이 있으면 `fields` 를 그룹으로 넘긴다. 규칙을 못 채운 동안 경고 툴팁이 떠 있고 `onSearch` 가 막힌다.
1087
1088
 
1089
+ #### 3-7-12. 이미지 — SImage
1090
+
1091
+ 사진·썸네일은 `<img>` 를 직접 쓰지 않고 `SImage` 를 쓴다. **로딩·실패 상태를 컴포넌트가 이미 처리하기 때문이다.**
1092
+
1093
+ - **로딩 스피너와 실패 표시를 앱이 만들지 않는다.** `src` 가 없거나 로드에 실패하면 빈 이미지 아이콘이, 로딩 중에는 스피너가 자동으로 놓인다. 크기는 상자 높이에 비례하므로 썸네일이든 큰 미리보기든 따로 맞출 것이 없다.
1094
+ - **크기는 `ratio` 와 너비로 준다.** `ratio` 기본값이 `1`(정사각형)이라 너비만 주면 정사각형이 된다. `className`/`style` 로 높이를 직접 주면 그쪽이 이기고 `ratio` 는 무시된다.
1095
+ - 상자를 채우는 방식은 `fit`(기본 `cover`), 붙는 위치는 `position` 이다. 안쪽 `<img>` 에 네이티브 속성이 필요하면 `imgProps` 로 넘긴다 — 목록에서 고스트 드래그를 막는 `imgProps={{ draggable: false }}` 가 대표적이다.
1096
+ - 썸네일이 많은 목록에서는 `loadingShowDelay` 를 준다. 캐시된 이미지가 즉시 로드될 때 스피너가 한 프레임 번쩍이는 것을 막는다.
1097
+
1098
+ ```tsx
1099
+ ✅ <SImage src={item.thumbnailUrl} alt={item.name} style={{ width: 64 }} /> {/* 정사각 썸네일 */}
1100
+ ✅ <SImage src={banner} ratio={16 / 9} className="w-full" /> {/* 가로형 배너 */}
1101
+
1102
+ ❌ <img src={item.thumbnailUrl} /> {/* 로딩·실패 상태가 없다 */}
1103
+ ❌ {loading ? <SCircleProgress indeterminate /> : <SImage src={url} />} {/* SImage 가 이미 한다 */}
1104
+ ```
1105
+
1088
1106
  ---
1089
1107
 
1090
1108
  ## 4. 페이지 레시피 — 표준 골격
@@ -11,6 +11,7 @@
11
11
  | `value?` | `number` | `0` | 진행률 (0–100) |
12
12
  | `type?` | `SCircleProgressType` | `'primary'` | 색상 테마 |
13
13
  | `indeterminate?` | `boolean` | `false` | 불확정(스피너) 모드 — value 무시 |
14
+ | `size?` | `number \| string` | `DEFAULT_SIZE` | 링의 지름. 숫자는 px 로 해석한다. 부모 크기에 비례시키려면 컨테이너 쿼리 단위(`'45cqh'`)를 쓴다 — 퍼센트는 부모가 inline-flex 라 기준 폭이 정해지지 않아 해석되지 않는다. |
14
15
  | `label?` | `string` | — | 하단 레이블 |
15
16
  | `innerValue?` | `boolean` | `false` | true면 퍼센트를 원 아래가 아닌 원 가운데에 표시 |
16
17
  | `className?` | `string` | — | |
@@ -20,6 +21,7 @@
20
21
 
21
22
  ### Used by
22
23
 
24
+ - [SImage](../SImage)
23
25
  - [SLoadingContainer](../SLoadingContainer)
24
26
  - [SLoadingModal](../SLoadingModal)
25
27
  - [STable](../STable)
@@ -28,6 +30,7 @@
28
30
 
29
31
  ```mermaid
30
32
  graph TD;
33
+ SImage --> SCircleProgress
31
34
  SLoadingContainer --> SCircleProgress
32
35
  SLoadingModal --> SCircleProgress
33
36
  STable --> SCircleProgress
@@ -1,5 +1,5 @@
1
1
  import { type CSSProperties } from 'react';
2
- export type SCircleProgressType = 'primary' | 'inverse' | 'error' | 'complete';
2
+ export type SCircleProgressType = 'primary' | 'inverse' | 'error' | 'complete' | 'neutral';
3
3
  export interface SCircleProgressProps {
4
4
  /** 진행률 (0–100) */
5
5
  value?: number;
@@ -7,6 +7,12 @@ export interface SCircleProgressProps {
7
7
  type?: SCircleProgressType;
8
8
  /** 불확정(스피너) 모드 — value 무시 */
9
9
  indeterminate?: boolean;
10
+ /**
11
+ * 링의 지름. 숫자는 px 로 해석한다.
12
+ * 부모 크기에 비례시키려면 컨테이너 쿼리 단위(`'45cqh'`)를 쓴다 — 퍼센트는 부모가
13
+ * inline-flex 라 기준 폭이 정해지지 않아 해석되지 않는다.
14
+ */
15
+ size?: number | string;
10
16
  /** 하단 레이블 */
11
17
  label?: string;
12
18
  /** true면 퍼센트를 원 아래가 아닌 원 가운데에 표시 */
@@ -36,6 +36,7 @@
36
36
  - [SGhostButton](../SGhostButton)
37
37
  - [SGnb](../SGnb)
38
38
  - [SGuide](../SGuide)
39
+ - [SImage](../SImage)
39
40
  - [SKeyValueTable](../SKeyValueTable)
40
41
  - [SListItem](../SListItem)
41
42
  - [SLoadingModal](../SLoadingModal)
@@ -73,6 +74,7 @@ graph TD;
73
74
  SGhostButton --> SIcon
74
75
  SGnb --> SIcon
75
76
  SGuide --> SIcon
77
+ SImage --> SIcon
76
78
  SKeyValueTable --> SIcon
77
79
  SListItem --> SIcon
78
80
  SLoadingModal --> SIcon
@@ -0,0 +1,44 @@
1
+ # SImage
2
+
3
+ > 자동 생성 문서 — `npm run docs:gen`. 소스: 각 컴포넌트의 Props/Handle 인터페이스 + import 의존성.
4
+
5
+ ### SImage
6
+
7
+ #### Props
8
+
9
+ | Prop | Type | Default | Description |
10
+ |------|------|---------|-------------|
11
+ | `src?` | `string` | — | 이미지 경로. 없으면 빈 이미지 아이콘이 나온다 |
12
+ | `alt?` | `string` | `''` | 대체 텍스트 |
13
+ | `ratio?` | `number` | `1` | 가로세로 비율. 소비자가 높이를 직접 주면 그쪽이 이긴다 |
14
+ | `fit?` | `SImageFit` | `'cover'` | 이미지가 상자를 채우는 방식 (object-fit) |
15
+ | `position?` | `string` | `'50% 50%'` | 이미지 정렬 위치 (object-position) |
16
+ | `loading?` | `'lazy' \| 'eager'` | `'lazy'` | 브라우저 로딩 방식 |
17
+ | `loadingShowDelay?` | `number` | `0` | 스피너를 띄우기까지 기다리는 시간(ms). 이 시간 안에 로드되면 스피너를 아예 그리지 않는다 — 캐시된 이미지에서 스피너가 한 프레임 번쩍이는 것을 막는다. |
18
+ | `noSpinner?` | `boolean` | `false` | 로딩 중에도 스피너를 그리지 않는다 |
19
+ | `loadingSlot?` | `ReactNode` | — | 로딩 중 표시를 직접 지정한다 (기본: 스피너) |
20
+ | `errorSlot?` | `ReactNode` | — | 실패·빈 상태 표시를 직접 지정한다 (기본: 빈 이미지 아이콘) |
21
+ | `imgProps?` | `ImgHTMLAttributes<HTMLImageElement>` | — | 안쪽 `<img>` 로 넘길 네이티브 속성 (srcSet · sizes · crossOrigin · draggable 등) |
22
+
23
+ #### Events
24
+
25
+ | Event | Type | Description |
26
+ |-------|------|-------------|
27
+ | `onLoad` | `(src: string) => void` | 로드 완료. 로드된 src 를 넘긴다 |
28
+ | `onError` | `(event: SyntheticEvent<HTMLImageElement>) => void` | 로드 실패 |
29
+
30
+ ## Dependencies
31
+
32
+ ### Depends on
33
+
34
+ - [SCircleProgress](../SCircleProgress)
35
+ - [SIcon](../SIcon)
36
+
37
+ ### Graph
38
+
39
+ ```mermaid
40
+ graph TD;
41
+ SImage --> SCircleProgress
42
+ SImage --> SIcon
43
+ style SImage fill:#f9f,stroke:#333,stroke-width:4px
44
+ ```
@@ -0,0 +1,41 @@
1
+ import { type HTMLAttributes, type ImgHTMLAttributes, type ReactNode, type SyntheticEvent } from 'react';
2
+ export declare const IMAGE_FITS: readonly ["cover", "contain", "fill", "none", "scale-down"];
3
+ export type SImageFit = (typeof IMAGE_FITS)[number];
4
+ export interface SImageProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onLoad' | 'onError'> {
5
+ /** 이미지 경로. 없으면 빈 이미지 아이콘이 나온다 */
6
+ src?: string;
7
+ /** 대체 텍스트 */
8
+ alt?: string;
9
+ /** 가로세로 비율. 소비자가 높이를 직접 주면 그쪽이 이긴다 */
10
+ ratio?: number;
11
+ /** 이미지가 상자를 채우는 방식 (object-fit) */
12
+ fit?: SImageFit;
13
+ /** 이미지 정렬 위치 (object-position) */
14
+ position?: string;
15
+ /** 브라우저 로딩 방식 */
16
+ loading?: 'lazy' | 'eager';
17
+ /**
18
+ * 스피너를 띄우기까지 기다리는 시간(ms). 이 시간 안에 로드되면 스피너를 아예 그리지 않는다 —
19
+ * 캐시된 이미지에서 스피너가 한 프레임 번쩍이는 것을 막는다.
20
+ */
21
+ loadingShowDelay?: number;
22
+ /** 로딩 중에도 스피너를 그리지 않는다 */
23
+ noSpinner?: boolean;
24
+ /** 로딩 중 표시를 직접 지정한다 (기본: 스피너) */
25
+ loadingSlot?: ReactNode;
26
+ /** 실패·빈 상태 표시를 직접 지정한다 (기본: 빈 이미지 아이콘) */
27
+ errorSlot?: ReactNode;
28
+ /** 안쪽 `<img>` 로 넘길 네이티브 속성 (srcSet · sizes · crossOrigin · draggable 등) */
29
+ imgProps?: ImgHTMLAttributes<HTMLImageElement>;
30
+ /** 로드 완료. 로드된 src 를 넘긴다 */
31
+ onLoad?: (src: string) => void;
32
+ /** 로드 실패 */
33
+ onError?: (event: SyntheticEvent<HTMLImageElement>) => void;
34
+ }
35
+ /**
36
+ * SImage — 이미지 로딩·실패 상태를 스스로 표현하는 이미지 상자.
37
+ *
38
+ * 로딩 중에는 스피너, `src` 가 없거나 로드에 실패하면 빈 이미지 아이콘을 컨테이너 높이에
39
+ * 비례한 크기로 가운데에 그린다.
40
+ */
41
+ export declare const SImage: import("react").ForwardRefExoticComponent<SImageProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1 @@
1
+ export { SImage, IMAGE_FITS, type SImageProps, type SImageFit } from './SImage';
@@ -11,6 +11,7 @@
11
11
  | `value?` | `boolean` | `false` | 선택(on/off) 상태 |
12
12
  | `label?` | `string` | `''` | 라벨 |
13
13
  | `disabled?` | `boolean` | `false` | 비활성 |
14
+ | `size?` | `SToggleSize` | `'sm'` | 크기 |
14
15
  | `className?` | `string` | — | |
15
16
  | `style?` | `CSSProperties` | — | |
16
17
 
@@ -1,4 +1,5 @@
1
1
  import { type CSSProperties } from 'react';
2
+ export type SToggleSize = 'xs' | 'sm';
2
3
  export interface SToggleProps {
3
4
  /** 선택(on/off) 상태 */
4
5
  value?: boolean;
@@ -6,6 +7,8 @@ export interface SToggleProps {
6
7
  label?: string;
7
8
  /** 비활성 */
8
9
  disabled?: boolean;
10
+ /** 크기 */
11
+ size?: SToggleSize;
9
12
  /** 변경 (sdUpdate) */
10
13
  onValueChange?: (value: boolean) => void;
11
14
  className?: string;
@@ -15,4 +18,4 @@ export interface SToggleProps {
15
18
  * SToggle — sd-toggle 포팅. 알약형 토글 버튼.
16
19
  * default(흰 배경/회색), hover(파란 채움), select(흰 배경/파란 테두리·글자), disabled.
17
20
  */
18
- export declare function SToggle({ value, label, disabled, onValueChange, className, style, }: SToggleProps): import("react").JSX.Element;
21
+ export declare function SToggle({ value, label, disabled, size, onValueChange, className, style, }: SToggleProps): import("react").JSX.Element;
package/dist/index.cjs CHANGED
@@ -3141,6 +3141,209 @@ var SIcon = /* @__PURE__ */ react.forwardRef(function SIcon2({ name, size = 24,
3141
3141
  }
3142
3142
  );
3143
3143
  });
3144
+ var VIEWBOX = 80;
3145
+ var RADIUS = 34;
3146
+ var STROKE = 12;
3147
+ var CIRCUMFERENCE = 2 * Math.PI * RADIUS;
3148
+ var COLOR_MAP = {
3149
+ primary: { arc: "var(--cmp-progress-color-active)", track: "var(--cmp-progress-color-track)" },
3150
+ // inverse: 어두운 배경용 — arc 흰색, track 은 흰색 40%(회색 링)
3151
+ inverse: { arc: "var(--color-white)", track: "rgba(255,255,255,0.4)" },
3152
+ error: { arc: "var(--color-red-75)", track: "var(--cmp-progress-color-track)" },
3153
+ complete: { arc: "#01BB4B", track: "var(--cmp-progress-color-track)" },
3154
+ // neutral: 콘텐츠가 아직 없는 자리를 채우는 용도 — arc 도 회색이라 진행률보다 "대기"로 읽힌다
3155
+ neutral: { arc: "var(--color-grey-35)", track: "var(--cmp-progress-color-track)" }
3156
+ };
3157
+ var DEFAULT_SIZE = 80;
3158
+ var clamp = (v) => Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0;
3159
+ var SCircleProgress = /* @__PURE__ */ react.forwardRef(
3160
+ function SCircleProgress2({
3161
+ value = 0,
3162
+ type = "primary",
3163
+ indeterminate = false,
3164
+ size = DEFAULT_SIZE,
3165
+ label,
3166
+ innerValue = false,
3167
+ className,
3168
+ style
3169
+ }, ref) {
3170
+ const v = clamp(value);
3171
+ const dimension = typeof size === "number" ? `${size}px` : size;
3172
+ const colors = COLOR_MAP[type];
3173
+ const c = VIEWBOX / 2;
3174
+ const percentColor = v === 0 ? "var(--sys-color-fg-tertiary, #888888)" : colors.arc;
3175
+ const filled = v / 100 * CIRCUMFERENCE;
3176
+ const showPercent = !indeterminate;
3177
+ const percentNode = /* @__PURE__ */ jsxRuntime.jsxs(
3178
+ "span",
3179
+ {
3180
+ className: cn(
3181
+ "text-center text-[16px] font-bold",
3182
+ innerValue ? (
3183
+ // leading-[normal]: 26px leading 은 폰트 content(19px) 위아래로 홀수(7px)만큼 남아
3184
+ // 3px/4px 로 쪼개지고 글자가 링 중심보다 위로 뜬다. (leading-normal 은 1.5 라 동일 문제)
3185
+ "absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 leading-[normal]"
3186
+ ) : "leading-[26px]"
3187
+ ),
3188
+ style: { color: percentColor },
3189
+ children: [
3190
+ Math.round(v),
3191
+ "%"
3192
+ ]
3193
+ }
3194
+ );
3195
+ return /* @__PURE__ */ jsxRuntime.jsxs(
3196
+ "div",
3197
+ {
3198
+ ref,
3199
+ className: cn(
3200
+ "inline-flex flex-col items-center gap-[var(--cmp-progress-circular-gap)]",
3201
+ className
3202
+ ),
3203
+ style,
3204
+ children: [
3205
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn(innerValue && "relative"), children: [
3206
+ /* @__PURE__ */ jsxRuntime.jsxs(
3207
+ "svg",
3208
+ {
3209
+ viewBox: `0 0 ${VIEWBOX} ${VIEWBOX}`,
3210
+ xmlns: "http://www.w3.org/2000/svg",
3211
+ className: cn(indeterminate && "origin-center animate-circle-spin"),
3212
+ style: { width: dimension, height: dimension },
3213
+ children: [
3214
+ /* @__PURE__ */ jsxRuntime.jsx(
3215
+ "circle",
3216
+ {
3217
+ cx: c,
3218
+ cy: c,
3219
+ r: RADIUS,
3220
+ fill: "none",
3221
+ strokeWidth: STROKE,
3222
+ style: { stroke: colors.track }
3223
+ }
3224
+ ),
3225
+ /* @__PURE__ */ jsxRuntime.jsx(
3226
+ "circle",
3227
+ {
3228
+ cx: c,
3229
+ cy: c,
3230
+ r: RADIUS,
3231
+ fill: "none",
3232
+ strokeWidth: STROKE,
3233
+ strokeLinecap: "round",
3234
+ className: cn(
3235
+ "origin-center",
3236
+ indeterminate ? "animate-circle-arc" : "-rotate-90 transition-[stroke-dasharray] duration-[400ms] ease-linear"
3237
+ ),
3238
+ style: {
3239
+ stroke: colors.arc,
3240
+ ...indeterminate ? {} : { strokeDasharray: `${filled} ${CIRCUMFERENCE}`, strokeDashoffset: 0 }
3241
+ }
3242
+ }
3243
+ )
3244
+ ]
3245
+ }
3246
+ ),
3247
+ showPercent && innerValue && percentNode
3248
+ ] }),
3249
+ showPercent && !innerValue && percentNode,
3250
+ label && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-center text-[12px] font-normal leading-[20px] text-[color:var(--cmp-progress-color-label)]", children: label })
3251
+ ]
3252
+ }
3253
+ );
3254
+ }
3255
+ );
3256
+ var IMAGE_FITS = ["cover", "contain", "fill", "none", "scale-down"];
3257
+ var EMPTY_ICON_SIZE = "60cqh";
3258
+ var SPINNER_SIZE = "45cqh";
3259
+ var SImage = /* @__PURE__ */ react.forwardRef(function SImage2({
3260
+ src,
3261
+ alt = "",
3262
+ ratio = 1,
3263
+ fit = "cover",
3264
+ position = "50% 50%",
3265
+ loading: loading2 = "lazy",
3266
+ loadingShowDelay = 0,
3267
+ noSpinner = false,
3268
+ loadingSlot,
3269
+ errorSlot,
3270
+ imgProps,
3271
+ onLoad,
3272
+ onError,
3273
+ className,
3274
+ style,
3275
+ ...rest
3276
+ }, ref) {
3277
+ const [status, setStatus] = react.useState("loading");
3278
+ const [spinnerVisible, setSpinnerVisible] = react.useState(loadingShowDelay <= 0);
3279
+ const [trackedSrc, setTrackedSrc] = react.useState(src);
3280
+ const imgRef = react.useRef(null);
3281
+ if (src !== trackedSrc) {
3282
+ setTrackedSrc(src);
3283
+ setStatus("loading");
3284
+ }
3285
+ react.useEffect(() => {
3286
+ if (!src) return;
3287
+ const node = imgRef.current;
3288
+ if (node?.complete && node.naturalWidth > 0) {
3289
+ setStatus((current) => current === "loading" ? "loaded" : current);
3290
+ }
3291
+ }, [src]);
3292
+ react.useEffect(() => {
3293
+ if (status !== "loading" || loadingShowDelay <= 0) {
3294
+ setSpinnerVisible(status === "loading");
3295
+ return;
3296
+ }
3297
+ setSpinnerVisible(false);
3298
+ const timer = setTimeout(() => setSpinnerVisible(true), loadingShowDelay);
3299
+ return () => clearTimeout(timer);
3300
+ }, [status, loadingShowDelay, src]);
3301
+ const isEmpty = !src;
3302
+ const showFallback = isEmpty || status === "error";
3303
+ const showSpinner = !isEmpty && status === "loading" && !noSpinner && spinnerVisible;
3304
+ return /* @__PURE__ */ jsxRuntime.jsxs(
3305
+ "div",
3306
+ {
3307
+ ref,
3308
+ className: cn("relative overflow-hidden bg-(--cmp-image-bg)", className),
3309
+ style: { containerType: "size", aspectRatio: ratio, ...style },
3310
+ ...rest,
3311
+ children: [
3312
+ !isEmpty && /* @__PURE__ */ jsxRuntime.jsx(
3313
+ "img",
3314
+ {
3315
+ ...imgProps,
3316
+ ref: imgRef,
3317
+ src,
3318
+ alt,
3319
+ loading: loading2,
3320
+ onLoad: () => {
3321
+ setStatus("loaded");
3322
+ onLoad?.(src);
3323
+ },
3324
+ onError: (event) => {
3325
+ setStatus("error");
3326
+ onError?.(event);
3327
+ },
3328
+ className: cn(
3329
+ "h-full w-full",
3330
+ // 로딩 중에도 자리를 지켜야 브라우저가 계속 받아온다 — 숨기되 없애지 않는다
3331
+ status === "loaded" ? "visible" : "invisible",
3332
+ imgProps?.className
3333
+ ),
3334
+ style: {
3335
+ objectFit: fit,
3336
+ objectPosition: position,
3337
+ ...imgProps?.style
3338
+ }
3339
+ }
3340
+ ),
3341
+ showFallback && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inset-0 flex items-center justify-center", children: errorSlot ?? /* @__PURE__ */ jsxRuntime.jsx(SIcon, { name: "image", size: EMPTY_ICON_SIZE, color: "var(--cmp-image-icon)" }) }),
3342
+ showSpinner && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inset-0 flex items-center justify-center", children: loadingSlot ?? /* @__PURE__ */ jsxRuntime.jsx(SCircleProgress, { indeterminate: true, type: "neutral", size: SPINNER_SIZE }) })
3343
+ ]
3344
+ }
3345
+ );
3346
+ });
3144
3347
  var BADGE_COLORS = [
3145
3348
  "red",
3146
3349
  "orange",
@@ -5582,14 +5785,14 @@ var SCallout = /* @__PURE__ */ react.forwardRef(function SCallout2({ type = "def
5582
5785
  );
5583
5786
  });
5584
5787
  var FILL_COLOR = {
5585
- primary: "var(--cmp-progress-active-color)",
5586
- error: "var(--cmp-progress-error-color)",
5587
- complete: "var(--cmp-progress-success-color)"
5788
+ primary: "var(--cmp-progress-color-active)",
5789
+ error: "var(--cmp-progress-color-error)",
5790
+ complete: "var(--cmp-progress-color-success)"
5588
5791
  };
5589
- var clamp = (v) => Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0;
5792
+ var clamp2 = (v) => Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0;
5590
5793
  var SLinearProgress = /* @__PURE__ */ react.forwardRef(
5591
5794
  function SLinearProgress2({ value = 0, type = "primary", indeterminate = false, label, className, style }, ref) {
5592
- const v = clamp(value);
5795
+ const v = clamp2(value);
5593
5796
  const fill = FILL_COLOR[type];
5594
5797
  const valueColor = v === 0 ? "var(--sys-color-fg-tertiary, #888888)" : fill;
5595
5798
  const valueText = `${Math.round(v)}%`;
@@ -5636,115 +5839,6 @@ var SLinearProgress = /* @__PURE__ */ react.forwardRef(
5636
5839
  );
5637
5840
  }
5638
5841
  );
5639
- var VIEWBOX = 80;
5640
- var RADIUS = 34;
5641
- var STROKE = 12;
5642
- var CIRCUMFERENCE = 2 * Math.PI * RADIUS;
5643
- var COLOR_MAP = {
5644
- primary: { arc: "var(--cmp-progress-active-color)", track: "var(--cmp-progress-color-track)" },
5645
- // inverse: 어두운 배경용 — arc 흰색, track 은 흰색 40%(회색 링)
5646
- inverse: { arc: "var(--color-white)", track: "rgba(255,255,255,0.4)" },
5647
- error: { arc: "var(--color-red-75)", track: "var(--cmp-progress-color-track)" },
5648
- complete: { arc: "#01BB4B", track: "var(--cmp-progress-color-track)" }
5649
- };
5650
- var clamp2 = (v) => Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0;
5651
- var SCircleProgress = /* @__PURE__ */ react.forwardRef(
5652
- function SCircleProgress2({
5653
- value = 0,
5654
- type = "primary",
5655
- indeterminate = false,
5656
- label,
5657
- innerValue = false,
5658
- className,
5659
- style
5660
- }, ref) {
5661
- const v = clamp2(value);
5662
- const colors = COLOR_MAP[type];
5663
- const c = VIEWBOX / 2;
5664
- const percentColor = v === 0 ? "var(--sys-color-fg-tertiary, #888888)" : colors.arc;
5665
- const filled = v / 100 * CIRCUMFERENCE;
5666
- const showPercent = !indeterminate;
5667
- const percentNode = /* @__PURE__ */ jsxRuntime.jsxs(
5668
- "span",
5669
- {
5670
- className: cn(
5671
- "text-center text-[16px] font-bold",
5672
- innerValue ? (
5673
- // leading-[normal]: 26px leading 은 폰트 content(19px) 위아래로 홀수(7px)만큼 남아
5674
- // 3px/4px 로 쪼개지고 글자가 링 중심보다 위로 뜬다. (leading-normal 은 1.5 라 동일 문제)
5675
- "absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 leading-[normal]"
5676
- ) : "leading-[26px]"
5677
- ),
5678
- style: { color: percentColor },
5679
- children: [
5680
- Math.round(v),
5681
- "%"
5682
- ]
5683
- }
5684
- );
5685
- return /* @__PURE__ */ jsxRuntime.jsxs(
5686
- "div",
5687
- {
5688
- ref,
5689
- className: cn(
5690
- "inline-flex flex-col items-center gap-[var(--cmp-progress-circular-gap)]",
5691
- className
5692
- ),
5693
- style,
5694
- children: [
5695
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn(innerValue && "relative"), children: [
5696
- /* @__PURE__ */ jsxRuntime.jsxs(
5697
- "svg",
5698
- {
5699
- viewBox: `0 0 ${VIEWBOX} ${VIEWBOX}`,
5700
- xmlns: "http://www.w3.org/2000/svg",
5701
- className: cn(
5702
- "h-[80px] w-[80px]",
5703
- indeterminate && "origin-center animate-circle-spin"
5704
- ),
5705
- children: [
5706
- /* @__PURE__ */ jsxRuntime.jsx(
5707
- "circle",
5708
- {
5709
- cx: c,
5710
- cy: c,
5711
- r: RADIUS,
5712
- fill: "none",
5713
- strokeWidth: STROKE,
5714
- style: { stroke: colors.track }
5715
- }
5716
- ),
5717
- /* @__PURE__ */ jsxRuntime.jsx(
5718
- "circle",
5719
- {
5720
- cx: c,
5721
- cy: c,
5722
- r: RADIUS,
5723
- fill: "none",
5724
- strokeWidth: STROKE,
5725
- strokeLinecap: "round",
5726
- className: cn(
5727
- "origin-center",
5728
- indeterminate ? "animate-circle-arc" : "-rotate-90 transition-[stroke-dasharray] duration-[400ms] ease-linear"
5729
- ),
5730
- style: {
5731
- stroke: colors.arc,
5732
- ...indeterminate ? {} : { strokeDasharray: `${filled} ${CIRCUMFERENCE}`, strokeDashoffset: 0 }
5733
- }
5734
- }
5735
- )
5736
- ]
5737
- }
5738
- ),
5739
- showPercent && innerValue && percentNode
5740
- ] }),
5741
- showPercent && !innerValue && percentNode,
5742
- label && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-center text-[12px] font-normal leading-[20px] text-[color:var(--cmp-progress-color-label)]", children: label })
5743
- ]
5744
- }
5745
- );
5746
- }
5747
- );
5748
5842
 
5749
5843
  // src/lib/global-overlay.ts
5750
5844
  var GLOBAL_OVERLAY_ATTR = "data-s-global-overlay";
@@ -6230,10 +6324,15 @@ function SSwitch({
6230
6324
  }
6231
6325
  );
6232
6326
  }
6327
+ var SIZE_CLASSES = {
6328
+ xs: "h-[24px] px-[8px] text-[12px] font-normal leading-[20px]",
6329
+ sm: "h-[28px] px-[12px] text-[14px] font-medium leading-[24px]"
6330
+ };
6233
6331
  function SToggle({
6234
6332
  value = false,
6235
6333
  label = "",
6236
6334
  disabled = false,
6335
+ size = "sm",
6237
6336
  onValueChange,
6238
6337
  className,
6239
6338
  style
@@ -6249,7 +6348,8 @@ function SToggle({
6249
6348
  "aria-pressed": value,
6250
6349
  onClick: () => !disabled && onValueChange?.(!value),
6251
6350
  className: cn(
6252
- "box-border inline-flex h-[28px] items-center justify-center whitespace-nowrap rounded-full border border-solid px-[12px] text-[14px] font-medium leading-[24px] transition-colors",
6351
+ "box-border inline-flex items-center justify-center whitespace-nowrap rounded-full border border-solid transition-colors",
6352
+ SIZE_CLASSES[size],
6253
6353
  disabled ? "cursor-not-allowed" : "cursor-pointer",
6254
6354
  // 미선택(off) hover 시 파란 채움 + 흰 글자
6255
6355
  !disabled && !value && "hover:!bg-[var(--cmp-toggle-bg-hover)] hover:!text-[color:var(--cmp-toggle-content-hover)] hover:!border-[color:var(--cmp-toggle-bg-hover)]",
@@ -16402,6 +16502,7 @@ exports.GNB_MENU_WIDTH = GNB_MENU_WIDTH;
16402
16502
  exports.GNB_RAIL = GNB_RAIL;
16403
16503
  exports.GNB_RAIL_WIDTH = GNB_RAIL_WIDTH;
16404
16504
  exports.ICONS = ICONS;
16505
+ exports.IMAGE_FITS = IMAGE_FITS;
16405
16506
  exports.PAGE_BACKGROUNDS = PAGE_BACKGROUNDS;
16406
16507
  exports.RangeCalendar = RangeCalendar;
16407
16508
  exports.SActionModal = SActionModal;
@@ -16436,6 +16537,7 @@ exports.SGhostButton = SGhostButton;
16436
16537
  exports.SGnb = SGnb;
16437
16538
  exports.SGuide = SGuide;
16438
16539
  exports.SIcon = SIcon;
16540
+ exports.SImage = SImage;
16439
16541
  exports.SInput = SInput;
16440
16542
  exports.SKeyValueTable = SKeyValueTable;
16441
16543
  exports.SLAYOUT_MIN_WIDTH = SLAYOUT_MIN_WIDTH;