sellmate-design-system-react 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +84 -21
  2. package/dist/components/SGnb/SGnb.cjs +238 -79
  3. package/dist/components/SGnb/SGnb.cjs.map +1 -1
  4. package/dist/components/SGnb/SGnb.d.ts +17 -4
  5. package/dist/components/SGnb/SGnb.js +238 -79
  6. package/dist/components/SGnb/SGnb.js.map +1 -1
  7. package/dist/components/SGnb/gnb.config.d.ts +46 -12
  8. package/dist/components/SGnb/index.d.ts +1 -1
  9. package/dist/components/SLayout/SLayout.cjs +20 -19
  10. package/dist/components/SLayout/SLayout.cjs.map +1 -1
  11. package/dist/components/SLayout/SLayout.d.ts +4 -0
  12. package/dist/components/SLayout/SLayout.js +20 -19
  13. package/dist/components/SLayout/SLayout.js.map +1 -1
  14. package/dist/components/SPage/SPage.cjs +21 -20
  15. package/dist/components/SPage/SPage.cjs.map +1 -1
  16. package/dist/components/SPage/SPage.js +21 -20
  17. package/dist/components/SPage/SPage.js.map +1 -1
  18. package/dist/components/STable/STable.cjs +1 -1
  19. package/dist/components/STable/STable.cjs.map +1 -1
  20. package/dist/components/STable/STable.js +1 -1
  21. package/dist/components/STable/STable.js.map +1 -1
  22. package/dist/components/STabs/STabs.cjs +15 -26
  23. package/dist/components/STabs/STabs.cjs.map +1 -1
  24. package/dist/components/STabs/STabs.d.ts +1 -1
  25. package/dist/components/STabs/STabs.js +15 -26
  26. package/dist/components/STabs/STabs.js.map +1 -1
  27. package/dist/components/STimePicker/STimePicker.cjs +3834 -0
  28. package/dist/components/STimePicker/STimePicker.cjs.map +1 -0
  29. package/dist/components/STimePicker/STimePicker.d.ts +50 -0
  30. package/dist/components/STimePicker/STimePicker.js +3811 -0
  31. package/dist/components/STimePicker/STimePicker.js.map +1 -0
  32. package/dist/components/STimePicker/index.d.ts +2 -0
  33. package/dist/components/STimePicker/timepicker.config.d.ts +23 -0
  34. package/dist/components/STimeRangePicker/STimeRangePicker.cjs +3910 -0
  35. package/dist/components/STimeRangePicker/STimeRangePicker.cjs.map +1 -0
  36. package/dist/components/STimeRangePicker/STimeRangePicker.d.ts +53 -0
  37. package/dist/components/STimeRangePicker/STimeRangePicker.js +3887 -0
  38. package/dist/components/STimeRangePicker/STimeRangePicker.js.map +1 -0
  39. package/dist/components/STimeRangePicker/index.d.ts +1 -0
  40. package/dist/components/STimeRangePicker/time-range-picker.config.d.ts +9 -0
  41. package/dist/index.cjs +1114 -108
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/index.d.ts +3 -0
  44. package/dist/index.js +1107 -109
  45. package/dist/index.js.map +1 -1
  46. package/dist/styles.css +143 -72
  47. package/dist/theme.css +81 -69
  48. package/package.json +4 -1
package/README.md CHANGED
@@ -21,8 +21,8 @@ npm install sellmate-design-system-react
21
21
 
22
22
  ```css
23
23
  /* app globals.css */
24
- @import "tailwindcss";
25
- @import "sellmate-design-system-react/theme.css";
24
+ @import 'tailwindcss';
25
+ @import 'sellmate-design-system-react/theme.css';
26
26
  @source "../node_modules/sellmate-design-system-react/dist";
27
27
  ```
28
28
 
@@ -34,37 +34,100 @@ npm install sellmate-design-system-react
34
34
  Tailwind를 쓰지 않는 앱은 컴파일된 CSS를 직접 import 합니다.
35
35
 
36
36
  ```ts
37
- import "sellmate-design-system-react/styles.css";
37
+ import 'sellmate-design-system-react/styles.css';
38
+ ```
39
+
40
+ ## Next.js (App Router)
41
+
42
+ 별도 설정 없이 동작합니다. `dist` 가 이미 빌드되어 있으므로 `transpilePackages` 는 필요 없습니다.
43
+
44
+ 전역 CSS 는 위 **방식 1** 과 같습니다. `@source` 는 **CSS 파일 기준 상대경로**이므로 `app/globals.css` 에 둔다면 다음과 같습니다.
45
+
46
+ ```css
47
+ /* app/globals.css */
48
+ @import 'tailwindcss';
49
+ @import 'sellmate-design-system-react/theme.css';
50
+ @source "../node_modules/sellmate-design-system-react/dist";
51
+ ```
52
+
53
+ ### 서버 컴포넌트에서 그대로 import 할 수 있습니다
54
+
55
+ 패키지 엔트리가 `"use client"` 로 선언된 클라이언트 경계라, 서버 컴포넌트에서 바로 import 해도 됩니다. 서버에서 렌더(SSR)된 뒤 클라이언트에서 하이드레이션됩니다.
56
+
57
+ ```tsx
58
+ // app/page.tsx — "use client" 없음
59
+ import { SButton } from 'sellmate-design-system-react';
60
+
61
+ export default function Page() {
62
+ return <SButton color="primary" size="md" label="확인" />;
63
+ }
64
+ ```
65
+
66
+ ### 이벤트 핸들러가 필요하면 소비자 쪽에 `"use client"`
67
+
68
+ RSC 경계를 넘어 **함수는 전달할 수 없습니다.** 위 페이지에 `onClick` 을 추가하면 빌드가 실패합니다.
69
+
70
+ ```text
71
+ Error: Event handlers cannot be passed to Client Component props.
72
+ ```
73
+
74
+ 이는 라이브러리 제약이 아니라 React 서버 컴포넌트의 규칙입니다. 핸들러·상태가 필요한 화면은 그 파일을 클라이언트 컴포넌트로 만드세요.
75
+
76
+ ```tsx
77
+ 'use client';
78
+
79
+ import { useState } from 'react';
80
+ import { SCheckbox } from 'sellmate-design-system-react';
81
+
82
+ export function AgreeField() {
83
+ const [value, setValue] = useState(false);
84
+ return <SCheckbox value={value} onValueChange={setValue} label="동의" />;
85
+ }
86
+ ```
87
+
88
+ 동작을 직접 확인하려면 Next playground 를 띄우세요 — 이 패키지를 소비 앱과 동일하게 `dist` 로 소비합니다.
89
+
90
+ ```bash
91
+ npm run playground:next # http://localhost:3010
38
92
  ```
39
93
 
40
94
  ## 사용법
41
95
 
42
96
  ```tsx
43
- import { Button } from "sellmate-design-system-react";
97
+ import { SButton } from 'sellmate-design-system-react';
44
98
 
45
99
  export function Example() {
46
- return (
47
- <Button variant="primary" size="md" onClick={() => alert("clicked")}>
48
- 확인
49
- </Button>
50
- );
100
+ return <SButton color="primary" size="md" label="확인" onClick={() => alert('clicked')} />;
51
101
  }
52
102
  ```
53
103
 
104
+ 모든 컴포넌트는 **`S` 접두어**를 씁니다(`SButton`, `SInput` …). 공개 타입도 마찬가지입니다(`SButtonProps`).
105
+
106
+ `SButton` 은 텍스트를 **`label`(문자열)로만** 받습니다 — `children` 은 받지 않으며, 아이콘은 `icon`/`rightIcon` 을 씁니다. 색상 prop 은 `color` 입니다(`variant` 아님).
107
+
54
108
  ## 제공 컴포넌트
55
109
 
56
- | 분류 | 컴포넌트 |
57
- | --- | --- |
58
- | 버튼·액션 | `Button` · `GhostButton` · `DropdownButton` · `TextLink` · `Toggle` · `Switch` |
59
- | 입력 | `Input` · `Textarea` · `NumberInput` · `BarcodeInput` · `Select` · `Checkbox` · `Radio` · `RadioButton` · `Chip` · `ChipInput` · `Field` · `FilePicker` |
60
- | 날짜 | `Calendar` · `DatePicker` · `DateRangePicker` |
61
- | 표시·피드백 | `Badge` · `Tag` · `Card` · `Callout` · `Divider` · `Tooltip` · `Popover` · `Popup` · `Guide` · `Toast` |
62
- | 진행 상태 | `LinearProgress` · `CircleProgress` · `LoadingContainer` · `LoadingModal` |
63
- | 모달 | `ModalContainer` · `ConfirmModal` · `ActionModal` |
64
- | 데이터 표시 | `Table` · `KeyValueTable` · `Tabs` · `Pagination` |
65
- | 아이콘 | `Icon` |
66
-
67
- 유틸리티로 클래스 병합 헬퍼 `cn`도 export 합니다.
110
+ 분류는 `src/index.ts` export 그룹과 동일합니다.
111
+
112
+ | 분류 | 컴포넌트 |
113
+ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
114
+ | Atoms | `SIcon` · `SBadge` · `SCard` · `SDivider` · `STag` · `SButton` · `SCallout` · `SLinearProgress` · `SCircleProgress` · `SLoadingContainer` |
115
+ | Control | `STextLink` · `SDropdownButton` · `SGhostButton` · `SSwitch` · `SToggle` |
116
+ | Overlays | `STooltip` · `SPopover` · `SPortal` · `SPopup` · `SGuide` |
117
+ | Modals | `SConfirmModal` · `SActionModal` · `SLoadingModal` |
118
+ | Layout | `SGnb` · `SLayout` · `SPage` |
119
+ | Navigation | `STabs` · `SPagination` |
120
+ | Data | `STable` · `SKeyValueTable` |
121
+ | Feedback | `SToast` |
122
+ | SField (폼) | `SForm` · `SField` · `SCheckbox` · `SRadio` · `SRadioButton` · `SInput` · `STextarea` · `SSelect` · `SNumberInput` · `SChip` · `SChipInput` · `SBarcodeInput` · `SCalendar` · `SDatePicker` · `SDateRangePicker` · `SFilePicker` |
123
+
124
+ `SModalContainer` 는 모달 3종의 내부 공통 컨테이너로 **공개 export 가 아닙니다.** 소비자는 `SConfirmModal`/`SActionModal`/`SLoadingModal` 을 사용하세요.
125
+
126
+ 유틸리티도 함께 export 합니다.
127
+
128
+ - `cn` — 클래스 병합 헬퍼
129
+ - `resolveColor` · `isColorKey` · `COLOR_KEYS` · `SColor` · `SColorKey` — 색상 토큰 유틸
130
+ - `Rule` — `rules` prop(`SField` · `SChip` · `SBarcodeInput` 등)에 넘기는 검증 함수 타입
68
131
 
69
132
  ## 특징
70
133
 
@@ -3095,73 +3095,70 @@ function SGhostButton({
3095
3095
  }
3096
3096
 
3097
3097
  // src/components/SGnb/gnb.config.ts
3098
- var belt = (p) => `var(--cmp-gnbBelt-${p})`;
3099
- var box = (p) => `var(--cmp-gnbBox-${p})`;
3100
- var light = (p) => `var(--cmp-light-${p})`;
3101
- var dark = (p) => `var(--cmp-dark-${p})`;
3098
+ var gnb = (p) => `var(--cmp-gnb-${p})`;
3099
+ var box = (p) => `var(--cmp-gnb-box-${p})`;
3100
+ var belt = (p) => `var(--cmp-gnb-belt-${p})`;
3101
+ var light = (p) => `var(--cmp-gnb-light-${p})`;
3102
+ var dark = (p) => `var(--cmp-gnb-dark-${p})`;
3103
+ var HEADER_TOP = {
3104
+ topHeight: gnb("top-height"),
3105
+ topPaddingX: gnb("top-paddingX"),
3106
+ topGap: gnb("top-gap"),
3107
+ logoHeight: gnb("logo-height")
3108
+ };
3102
3109
  var GNB_HEADER = {
3103
- fix: {
3104
- topHeight: belt("top-height"),
3105
- topPaddingX: belt("top-paddingX"),
3106
- topGap: belt("top-gap"),
3107
- logoHeight: belt("logo-height")
3108
- },
3109
- full: {
3110
- topHeight: box("top-height"),
3111
- topPaddingX: box("paddingX"),
3112
- topGap: box("gap"),
3113
- // 상단바 gap (버튼 간 세부 gap 은 --cmp-gnbBox-button-gap)
3114
- logoHeight: box("logo-height")
3115
- }
3110
+ fix: HEADER_TOP,
3111
+ full: HEADER_TOP
3116
3112
  };
3117
3113
  var GNB_MENU = {
3118
3114
  box: {
3119
- // menu.body.padding 전방향 토큰 세로 패딩과 아이템 좌우 여백 양쪽에 쓴다
3120
- bodyPaddingY: box("menu-body-padding"),
3121
- itemMarginX: box("menu-body-padding"),
3122
- itemPaddingY: box("item-paddingY"),
3115
+ // 바디 패딩·세로 패딩·chevron gnb 통합된 공통 토큰
3116
+ bodyPaddingY: gnb("body-padding"),
3117
+ itemMarginX: gnb("body-padding"),
3118
+ // 전방향 토큰 → 아이템 좌우 여백에도 사용
3119
+ itemPaddingY: gnb("item-paddingY"),
3123
3120
  itemPaddingRight: box("item-paddingRight"),
3124
3121
  itemGap: box("item-gap"),
3125
3122
  itemRadius: box("item-radius"),
3126
- itemArrow: box("item-arrow"),
3123
+ itemArrow: gnb("item-chevron-size"),
3127
3124
  depth1PaddingLeft: box("item-depth1-paddingLeft"),
3128
3125
  depth2PaddingLeft: box("item-depth2-paddingLeft"),
3129
3126
  depth3PaddingLeft: box("item-depth3-paddingLeft"),
3130
3127
  depth1Icon: box("item-depth1-icon"),
3131
3128
  depth1FontSize: 14,
3132
- // token: gnbBox.item.typography.bold.fontSize
3129
+ // token: gnb.box.item.typography.selected.fontSize
3133
3130
  depth1LineHeight: 24,
3134
- // token: gnbBox.item.typography.bold.lineHeight
3131
+ // token: gnb.box.item.typography.selected.lineHeight
3135
3132
  depth1FontWeight: 700,
3136
3133
  itemFontSize: 14,
3137
- // token: gnbBox.item.typography.default.fontSize
3134
+ // token: gnb.box.item.typography.default.fontSize
3138
3135
  itemLineHeight: 24
3139
- // token: gnbBox.item.typography.default.lineHeight
3136
+ // token: gnb.box.item.typography.default.lineHeight
3140
3137
  },
3141
3138
  belt: {
3142
- bodyPaddingY: belt("body-paddingY"),
3139
+ bodyPaddingY: gnb("body-padding"),
3143
3140
  itemMarginX: "0px",
3144
3141
  // belt 토큰에 가로 여백 없음 → 풀폭 행 (들여쓰기는 depth*-paddingLeft 전담)
3145
- itemPaddingY: belt("item-paddingY"),
3142
+ itemPaddingY: gnb("item-paddingY"),
3146
3143
  itemPaddingRight: belt("item-paddingRight"),
3147
3144
  itemGap: belt("item-gap"),
3148
3145
  itemRadius: "0px",
3149
3146
  // belt 토큰에 radius 없음 → 풀폭 행
3150
- itemArrow: belt("item-icon"),
3147
+ itemArrow: gnb("item-chevron-size"),
3151
3148
  depth1PaddingLeft: belt("item-depth1-paddingLeft"),
3152
3149
  depth2PaddingLeft: belt("item-depth2-paddingLeft"),
3153
3150
  depth3PaddingLeft: belt("item-depth3-paddingLeft"),
3154
- depth1Icon: box("item-depth1-icon"),
3155
- // belt 토큰에 depth1-icon 없음 → box 값(20) 공유
3151
+ depth1Icon: belt("item-depth1-icon"),
3152
+ // belt 전용 depth1 아이콘 토큰(16)
3156
3153
  depth1FontSize: 13,
3157
- // token: gnbBelt.item.depth1.typography.fontSize
3154
+ // token: gnb.belt.item.depth1.typography.fontSize
3158
3155
  depth1LineHeight: 22,
3159
- // token: gnbBelt.item.depth1.typography.lineHeight
3156
+ // token: gnb.belt.item.depth1.typography.lineHeight
3160
3157
  depth1FontWeight: 700,
3161
3158
  itemFontSize: 12,
3162
- // token: gnbBelt.item.typography.default.fontSize
3159
+ // token: gnb.belt.item.typography.default.fontSize
3163
3160
  itemLineHeight: 20
3164
- // token: gnbBelt.item.typography.default.lineHeight
3161
+ // token: gnb.belt.item.typography.default.lineHeight
3165
3162
  }
3166
3163
  };
3167
3164
  var GNB_COLORS = {
@@ -3174,7 +3171,9 @@ var GNB_COLORS = {
3174
3171
  itemColorDefault: light("item-fg-default"),
3175
3172
  itemColorHover: light("item-fg-hover"),
3176
3173
  itemColorActive: light("item-fg-selected"),
3177
- itemChevron: light("item-chevron")
3174
+ itemChevron: light("item-chevron"),
3175
+ itemIcon: "grey_65",
3176
+ railFgDefault: light("rail-fg-default")
3178
3177
  },
3179
3178
  dark: {
3180
3179
  panelBg: dark("bg"),
@@ -3185,19 +3184,41 @@ var GNB_COLORS = {
3185
3184
  itemColorDefault: dark("item-fg-default"),
3186
3185
  itemColorHover: dark("item-fg-hover"),
3187
3186
  itemColorActive: dark("item-fg-selected"),
3188
- itemChevron: dark("item-chevron")
3187
+ itemChevron: dark("item-chevron"),
3188
+ itemIcon: "grey_55",
3189
+ railFgDefault: dark("rail-fg-default")
3189
3190
  }
3190
3191
  };
3191
3192
  var GNB_COMMON = {
3192
3193
  /** 접힘 레일 폭. header="full" 은 접으면 메뉴만 사라져 레일이 없으므로 fix 전용. */
3193
- foldWidth: belt("fold-width"),
3194
- /**
3195
- * 상단바 아이콘 버튼 사이 간격. fix 레일(세로 스택)의 gap·상하 여백에 쓴다.
3196
- * 레일 전용 여백 토큰이 따로 없어 같은 값(8px)인 이 토큰으로 재해석했다.
3197
- */
3198
- buttonGap: box("button-gap")
3199
- };
3194
+ foldWidth: gnb("fold-width")
3195
+ };
3196
+ var GNB_RAIL = {
3197
+ padding: gnb("rail-padding"),
3198
+ gap: gnb("rail-gap"),
3199
+ itemWidth: gnb("rail-item-width"),
3200
+ itemPaddingTop: gnb("rail-item-paddingTop"),
3201
+ itemPaddingBottom: gnb("rail-item-paddingBottom"),
3202
+ itemRadius: gnb("rail-item-radius"),
3203
+ itemIcon: gnb("rail-item-icon"),
3204
+ // 타이포 (theme.css 에 gnb 타이포 변수 없음 → 토큰 값 직접 명시)
3205
+ itemFontSize: 11,
3206
+ // token: gnb.rail.item.typography.default.fontSize
3207
+ itemLineHeight: 18,
3208
+ // token: gnb.rail.item.typography.default.lineHeight
3209
+ itemFontWeight: 500,
3210
+ // token: gnb.rail.item.typography.default.fontWeight
3211
+ itemFontWeightSelected: 700
3212
+ // token: gnb.rail.item.typography.selected.fontWeight
3213
+ };
3214
+ var GNB_RAIL_WIDTH = 68;
3215
+ var GNB_FOLD_MS = 200;
3216
+ var GNB_EXPAND_MS = 200;
3200
3217
  var GNB_MENU_WIDTH = 240;
3218
+ function findGnbRailValue(items, value) {
3219
+ if (items.some((item) => item.value === value)) return value;
3220
+ return findGnbAncestors(items, value)[0];
3221
+ }
3201
3222
  function findGnbAncestors(items, value) {
3202
3223
  const walk = (list, trail) => {
3203
3224
  for (const item of list) {
@@ -3215,6 +3236,7 @@ var SLayoutContext = react.createContext(null);
3215
3236
  react.forwardRef(function SLayout2({
3216
3237
  type = "box",
3217
3238
  header = "fix",
3239
+ useRail = false,
3218
3240
  folded,
3219
3241
  defaultFolded = false,
3220
3242
  onFoldedChange,
@@ -3234,13 +3256,17 @@ react.forwardRef(function SLayout2({
3234
3256
  [isControlled, onFoldedChange]
3235
3257
  );
3236
3258
  const ctx = react.useMemo(
3237
- () => ({ type, header, folded: currentFolded, setFolded }),
3238
- [type, header, currentFolded, setFolded]
3259
+ () => ({ type, header, useRail, folded: currentFolded, setFolded }),
3260
+ [type, header, useRail, currentFolded, setFolded]
3239
3261
  );
3240
3262
  const isFullHeader = header === "full";
3263
+ const gnbColumnWidth = useRail ? GNB_RAIL_WIDTH + GNB_MENU_WIDTH : GNB_MENU_WIDTH;
3241
3264
  const frameStyle = isFullHeader ? {
3242
- gridTemplateColumns: `${currentFolded ? 0 : GNB_MENU_WIDTH}px 1fr`,
3243
- gridTemplateRows: `${GNB_HEADER[header].topHeight} 1fr`
3265
+ gridTemplateColumns: `${currentFolded ? 0 : gnbColumnWidth}px 1fr`,
3266
+ gridTemplateRows: `${GNB_HEADER[header].topHeight} 1fr`,
3267
+ // 메뉴가 미끄러지는 동안 페이지 열도 같은 길이로 따라와야 한 덩어리로 읽힌다.
3268
+ // (fix 는 SGnb 가 제 폭을 직접 전환하므로 프레임이 손댈 게 없다)
3269
+ transition: `grid-template-columns ${GNB_FOLD_MS}ms ease-out`
3244
3270
  } : void 0;
3245
3271
  return /* @__PURE__ */ jsxRuntime.jsx(SLayoutContext.Provider, { value: ctx, children: /* @__PURE__ */ jsxRuntime.jsx(
3246
3272
  "div",
@@ -3265,8 +3291,10 @@ var SGnb = react.forwardRef(function SGnb2({
3265
3291
  header: headerProp,
3266
3292
  color = "light",
3267
3293
  items,
3294
+ useRail: useRailProp,
3268
3295
  value = "",
3269
3296
  onValueChange,
3297
+ onRailChange,
3270
3298
  folded: foldedProp,
3271
3299
  onFoldChange,
3272
3300
  onLauncherClick,
@@ -3280,6 +3308,7 @@ var SGnb = react.forwardRef(function SGnb2({
3280
3308
  const type = typeProp ?? layout?.type ?? "box";
3281
3309
  const header = headerProp ?? layout?.header ?? "fix";
3282
3310
  const folded = foldedProp ?? layout?.folded ?? false;
3311
+ const useRail = useRailProp ?? layout?.useRail ?? false;
3283
3312
  const H = GNB_HEADER[header];
3284
3313
  const M = GNB_MENU[type];
3285
3314
  const C = GNB_COLORS[color];
@@ -3287,6 +3316,9 @@ var SGnb = react.forwardRef(function SGnb2({
3287
3316
  () => new Set(findGnbAncestors(items, value))
3288
3317
  );
3289
3318
  const [hoveredKey, setHoveredKey] = react.useState(null);
3319
+ const [activeRail, setActiveRail] = react.useState(
3320
+ () => findGnbRailValue(items, value) ?? items[0]?.value
3321
+ );
3290
3322
  react.useEffect(() => {
3291
3323
  const ancestors = findGnbAncestors(items, value);
3292
3324
  if (ancestors.length === 0) return;
@@ -3297,6 +3329,15 @@ var SGnb = react.forwardRef(function SGnb2({
3297
3329
  return next;
3298
3330
  });
3299
3331
  }, [items, value]);
3332
+ react.useEffect(() => {
3333
+ const owner = findGnbRailValue(items, value);
3334
+ if (owner !== void 0) setActiveRail(owner);
3335
+ }, [items, value]);
3336
+ react.useEffect(() => {
3337
+ setActiveRail(
3338
+ (prev) => prev !== void 0 && items.some((item) => item.value === prev) ? prev : items[0]?.value
3339
+ );
3340
+ }, [items]);
3300
3341
  const toggleExpand = (key) => {
3301
3342
  setExpandedKeys((prev) => {
3302
3343
  const next = new Set(prev);
@@ -3313,16 +3354,58 @@ var SGnb = react.forwardRef(function SGnb2({
3313
3354
  }
3314
3355
  onValueChange?.(item.value);
3315
3356
  };
3357
+ const handleRailClick = (item) => {
3358
+ if (item.disabled) return;
3359
+ setActiveRail(item.value);
3360
+ onRailChange?.(item.value);
3361
+ if (!item.children || item.children.length === 0) onValueChange?.(item.value);
3362
+ };
3316
3363
  const handleFoldToggle = () => {
3317
3364
  const next = !folded;
3318
3365
  onFoldChange?.(next);
3319
3366
  if (foldedProp === void 0) layout?.setFolded(next);
3320
3367
  };
3321
- const renderItem = (item, depth) => {
3368
+ const renderRailItem = (item) => {
3369
+ const isActive = item.value === activeRail;
3370
+ const isHovered = hoveredKey === item.value && !item.disabled;
3371
+ return /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsxs(
3372
+ "button",
3373
+ {
3374
+ type: "button",
3375
+ disabled: item.disabled,
3376
+ "aria-current": isActive ? "true" : void 0,
3377
+ className: "box-border flex select-none flex-col items-center border-none bg-transparent transition-colors duration-150",
3378
+ style: {
3379
+ width: GNB_RAIL.itemWidth,
3380
+ paddingTop: GNB_RAIL.itemPaddingTop,
3381
+ paddingBottom: GNB_RAIL.itemPaddingBottom,
3382
+ borderRadius: GNB_RAIL.itemRadius,
3383
+ backgroundColor: isActive ? C.itemBgSelected : isHovered ? C.itemBgHover : C.itemBgDefault,
3384
+ color: isActive ? C.itemColorActive : C.railFgDefault,
3385
+ // <button> 의 UA 폰트를 끊고 앱 폰트를 잇는다 (메뉴 아이템과 같은 이유)
3386
+ fontFamily: "inherit",
3387
+ fontSize: GNB_RAIL.itemFontSize,
3388
+ lineHeight: `${GNB_RAIL.itemLineHeight}px`,
3389
+ fontWeight: isActive ? GNB_RAIL.itemFontWeightSelected : GNB_RAIL.itemFontWeight,
3390
+ opacity: item.disabled ? 0.4 : 1,
3391
+ cursor: item.disabled ? "not-allowed" : "pointer"
3392
+ },
3393
+ onClick: () => handleRailClick(item),
3394
+ onMouseEnter: () => setHoveredKey(item.value),
3395
+ onMouseLeave: () => setHoveredKey((prev) => prev === item.value ? null : prev),
3396
+ children: [
3397
+ item.icon && /* @__PURE__ */ jsxRuntime.jsx(SIcon, { name: item.icon, size: GNB_RAIL.itemIcon, className: "shrink-0" }),
3398
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "w-full overflow-hidden text-ellipsis whitespace-nowrap text-center", children: item.label })
3399
+ ]
3400
+ }
3401
+ ) }, item.value);
3402
+ };
3403
+ const renderItem = (item, depth, branchHasIcon = false) => {
3322
3404
  const hasChildren = !!(item.children && item.children.length > 0);
3323
3405
  const isSelected = !hasChildren && item.value === value;
3324
3406
  const isExpanded = expandedKeys.has(item.value);
3325
3407
  const isHovered = hoveredKey === item.value && !item.disabled;
3408
+ const iconBranch = depth === 1 ? !!item.icon : branchHasIcon;
3326
3409
  const itemStyle = {
3327
3410
  gap: M.itemGap,
3328
3411
  paddingTop: M.itemPaddingY,
@@ -3359,8 +3442,11 @@ var SGnb = react.forwardRef(function SGnb2({
3359
3442
  onMouseEnter: () => setHoveredKey(item.value),
3360
3443
  onMouseLeave: () => setHoveredKey((prev) => prev === item.value ? null : prev),
3361
3444
  children: [
3362
- depth === 1 && item.icon && /* @__PURE__ */ jsxRuntime.jsx(SIcon, { name: item.icon, size: M.depth1Icon, className: "shrink-0" }),
3363
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap", children: item.label }),
3445
+ depth === 1 && item.icon && /* @__PURE__ */ jsxRuntime.jsx(SIcon, { name: item.icon, size: M.depth1Icon, color: C.itemIcon, className: "shrink-0" }),
3446
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap flex flex-no-wrap items-center", children: [
3447
+ depth > 1 && iconBranch ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-[20px] h-[24px] flex items-center justify-center mr-[8px]", children: "-" }) : null,
3448
+ item.label
3449
+ ] }),
3364
3450
  item.tag !== void 0 && item.tag !== "" && /* @__PURE__ */ jsxRuntime.jsx(
3365
3451
  STag,
3366
3452
  {
@@ -3378,28 +3464,53 @@ var SGnb = react.forwardRef(function SGnb2({
3378
3464
  size: M.itemArrow,
3379
3465
  color: C.itemChevron,
3380
3466
  rotate: isExpanded ? 180 : 0,
3381
- className: "shrink-0 transition-transform duration-150"
3467
+ className: "shrink-0 transition-transform duration-200"
3382
3468
  }
3383
3469
  )
3384
3470
  ]
3385
3471
  }
3386
3472
  ),
3387
- hasChildren && isExpanded && /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "m-0 flex list-none flex-col p-0", children: item.children.map((child) => renderItem(child, depth + 1)) })
3473
+ hasChildren && /* @__PURE__ */ jsxRuntime.jsx(
3474
+ "div",
3475
+ {
3476
+ className: "grid",
3477
+ style: {
3478
+ gridTemplateRows: isExpanded ? "1fr" : "0fr",
3479
+ transition: `grid-template-rows ${GNB_EXPAND_MS}ms ease-out`
3480
+ },
3481
+ children: /* @__PURE__ */ jsxRuntime.jsx(
3482
+ "ul",
3483
+ {
3484
+ className: "m-0 flex min-h-0 list-none flex-col overflow-hidden p-0",
3485
+ style: {
3486
+ visibility: isExpanded ? "visible" : "hidden",
3487
+ transition: `visibility 0s linear ${isExpanded ? "0s" : `${GNB_EXPAND_MS}ms`}`
3488
+ },
3489
+ children: item.children.map((child) => renderItem(child, depth + 1, iconBranch))
3490
+ }
3491
+ )
3492
+ }
3493
+ )
3388
3494
  ] }, item.value)
3389
3495
  );
3390
3496
  };
3497
+ const railItems = useRail ? items : [];
3498
+ const menuItems = useRail ? items.find((item) => item.value === activeRail)?.children ?? [] : items;
3499
+ const bodyWidth = useRail ? GNB_RAIL_WIDTH + GNB_MENU_WIDTH : GNB_MENU_WIDTH;
3391
3500
  const railFolded = folded && header === "fix";
3392
- const rootWidth = header === "full" ? "100%" : folded ? GNB_COMMON.foldWidth : GNB_MENU_WIDTH;
3501
+ const rootWidth = header === "full" ? "100%" : folded ? GNB_COMMON.foldWidth : bodyWidth;
3393
3502
  const rootStyle = {
3394
3503
  width: rootWidth,
3504
+ transition: `width ${GNB_FOLD_MS}ms ease-out`,
3395
3505
  ...header === "full" ? { gridArea: "1 / 1 / -1 / -1", pointerEvents: "none" } : { backgroundColor: C.panelBg },
3396
3506
  ...style
3397
3507
  };
3508
+ const hideAfterSlide = (hidden) => `visibility 0s linear ${hidden ? `${GNB_FOLD_MS}ms` : "0s"}`;
3398
3509
  return /* @__PURE__ */ jsxRuntime.jsxs(
3399
3510
  "div",
3400
3511
  {
3401
3512
  ref,
3402
- className: cn("box-border flex h-full shrink-0 flex-col", className),
3513
+ className: cn("box-border flex h-full shrink-0 flex-col overflow-hidden", className),
3403
3514
  style: rootStyle,
3404
3515
  "data-type": type,
3405
3516
  "data-header": header,
@@ -3409,30 +3520,47 @@ var SGnb = react.forwardRef(function SGnb2({
3409
3520
  /* @__PURE__ */ jsxRuntime.jsxs(
3410
3521
  "div",
3411
3522
  {
3412
- className: cn("box-border flex items-center", railFolded && "flex-col"),
3523
+ className: "relative box-border flex shrink-0 items-center overflow-hidden",
3413
3524
  style: {
3414
- gap: railFolded ? GNB_COMMON.buttonGap : H.topGap,
3415
- height: railFolded ? "auto" : H.topHeight,
3416
- padding: railFolded ? `${GNB_COMMON.buttonGap} 0` : `0 ${H.topPaddingX}`,
3525
+ height: H.topHeight,
3526
+ padding: `0 ${railFolded ? GNB_RAIL.padding : H.topPaddingX}`,
3417
3527
  borderBottom: `1px solid ${C.topBorder}`,
3418
3528
  backgroundColor: C.panelBg,
3419
3529
  pointerEvents: "auto",
3530
+ transition: `padding ${GNB_FOLD_MS}ms ease-out`,
3420
3531
  // 아이콘 버튼은 자체 ghostButton 토큰을 쓰므로, 이 색은 logo 슬롯 상속용
3421
3532
  color: C.itemColorDefault
3422
3533
  },
3423
3534
  children: [
3424
- onLauncherClick && /* @__PURE__ */ jsxRuntime.jsx(
3425
- SGhostButton,
3535
+ (onLauncherClick || logo) && /* @__PURE__ */ jsxRuntime.jsxs(
3536
+ "div",
3426
3537
  {
3427
- icon: "launcher",
3428
- size: "xs",
3429
- intent: color === "dark" ? "inverse" : "default",
3430
- ariaLabel: "app launcher",
3431
- onClick: onLauncherClick,
3432
- className: "shrink-0"
3538
+ className: "absolute inset-y-0 flex items-center",
3539
+ style: {
3540
+ left: H.topPaddingX,
3541
+ gap: H.topGap,
3542
+ opacity: railFolded ? 0 : 1,
3543
+ transition: `opacity ${GNB_FOLD_MS}ms ease-out, ${hideAfterSlide(railFolded)}, transform ${GNB_FOLD_MS}ms ease-out`,
3544
+ // 메뉴와 같은 방향으로 빠지되, 좁은 레일 안에서 꼬리가 비치지 않게 페이드가 거리를 대신한다
3545
+ transform: railFolded ? `translateX(-${H.topPaddingX})` : "none",
3546
+ visibility: railFolded ? "hidden" : "visible"
3547
+ },
3548
+ children: [
3549
+ onLauncherClick && /* @__PURE__ */ jsxRuntime.jsx(
3550
+ SGhostButton,
3551
+ {
3552
+ icon: "launcher",
3553
+ size: "xs",
3554
+ intent: color === "dark" ? "inverse" : "default",
3555
+ ariaLabel: "app launcher",
3556
+ onClick: onLauncherClick,
3557
+ className: "shrink-0"
3558
+ }
3559
+ ),
3560
+ logo && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center", style: { height: H.logoHeight }, children: logo })
3561
+ ]
3433
3562
  }
3434
3563
  ),
3435
- !railFolded && logo && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center", style: { height: H.logoHeight }, children: logo }),
3436
3564
  /* @__PURE__ */ jsxRuntime.jsx(
3437
3565
  SGhostButton,
3438
3566
  {
@@ -3442,30 +3570,61 @@ var SGnb = react.forwardRef(function SGnb2({
3442
3570
  ariaLabel: "toggle navigation",
3443
3571
  ariaPressed: folded,
3444
3572
  onClick: handleFoldToggle,
3445
- className: cn("shrink-0", !railFolded && "ml-auto")
3573
+ className: "ml-auto shrink-0"
3446
3574
  }
3447
3575
  )
3448
3576
  ]
3449
3577
  }
3450
3578
  ),
3451
- !folded && /* @__PURE__ */ jsxRuntime.jsx(
3452
- "nav",
3579
+ /* @__PURE__ */ jsxRuntime.jsxs(
3580
+ "div",
3453
3581
  {
3454
- "aria-label": ariaLabel,
3455
- className: "flex flex-col overflow-y-auto",
3582
+ className: "flex flex-row",
3456
3583
  style: {
3457
- width: GNB_MENU_WIDTH,
3584
+ width: bodyWidth,
3458
3585
  alignSelf: "flex-start",
3459
3586
  flexGrow: 1,
3460
3587
  flexShrink: 1,
3461
3588
  flexBasis: "auto",
3462
3589
  minHeight: 0,
3463
- paddingTop: M.bodyPaddingY,
3464
- paddingBottom: M.bodyPaddingY,
3465
- backgroundColor: C.panelBg,
3466
- pointerEvents: "auto"
3590
+ transform: folded ? "translateX(-100%)" : "none",
3591
+ visibility: folded ? "hidden" : "visible",
3592
+ transition: `transform ${GNB_FOLD_MS}ms ease-out, ${hideAfterSlide(folded)}`
3467
3593
  },
3468
- children: /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "m-0 flex list-none flex-col p-0", children: items.map((item) => renderItem(item, 1)) })
3594
+ children: [
3595
+ useRail && // 레일도 내비게이션 landmark — nav 로 세워야 aria-label(레일)이 스크린리더에 노출된다
3596
+ // (role 없는 ul 의 aria-label 은 영역명으로 읽히지 않는다). 메뉴 nav 와는 레이블로 구분된다.
3597
+ /* @__PURE__ */ jsxRuntime.jsx(
3598
+ "nav",
3599
+ {
3600
+ "aria-label": `${ariaLabel} rail`,
3601
+ className: "flex shrink-0 flex-col overflow-y-auto",
3602
+ style: {
3603
+ width: GNB_RAIL_WIDTH,
3604
+ padding: GNB_RAIL.padding,
3605
+ borderRight: `1px solid ${C.topBorder}`,
3606
+ backgroundColor: C.panelBg,
3607
+ pointerEvents: "auto"
3608
+ },
3609
+ children: /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "m-0 flex list-none flex-col p-0", style: { gap: GNB_RAIL.gap }, children: railItems.map(renderRailItem) })
3610
+ }
3611
+ ),
3612
+ /* @__PURE__ */ jsxRuntime.jsx(
3613
+ "nav",
3614
+ {
3615
+ "aria-label": ariaLabel,
3616
+ className: "flex shrink-0 flex-col overflow-y-auto",
3617
+ style: {
3618
+ width: GNB_MENU_WIDTH,
3619
+ paddingTop: M.bodyPaddingY,
3620
+ paddingBottom: M.bodyPaddingY,
3621
+ backgroundColor: C.panelBg,
3622
+ pointerEvents: "auto"
3623
+ },
3624
+ children: /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "m-0 flex list-none flex-col p-0", children: menuItems.map((item) => renderItem(item, 1)) })
3625
+ }
3626
+ )
3627
+ ]
3469
3628
  }
3470
3629
  )
3471
3630
  ]