sellmate-design-system-react 0.4.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 (34) hide show
  1. package/dist/components/SGnb/SGnb.cjs +238 -79
  2. package/dist/components/SGnb/SGnb.cjs.map +1 -1
  3. package/dist/components/SGnb/SGnb.d.ts +17 -4
  4. package/dist/components/SGnb/SGnb.js +238 -79
  5. package/dist/components/SGnb/SGnb.js.map +1 -1
  6. package/dist/components/SGnb/gnb.config.d.ts +46 -12
  7. package/dist/components/SGnb/index.d.ts +1 -1
  8. package/dist/components/SLayout/SLayout.cjs +20 -19
  9. package/dist/components/SLayout/SLayout.cjs.map +1 -1
  10. package/dist/components/SLayout/SLayout.d.ts +4 -0
  11. package/dist/components/SLayout/SLayout.js +20 -19
  12. package/dist/components/SLayout/SLayout.js.map +1 -1
  13. package/dist/components/SPage/SPage.cjs +21 -20
  14. package/dist/components/SPage/SPage.cjs.map +1 -1
  15. package/dist/components/SPage/SPage.js +21 -20
  16. package/dist/components/SPage/SPage.js.map +1 -1
  17. package/dist/components/STabs/STabs.cjs +15 -26
  18. package/dist/components/STabs/STabs.cjs.map +1 -1
  19. package/dist/components/STabs/STabs.d.ts +1 -1
  20. package/dist/components/STabs/STabs.js +15 -26
  21. package/dist/components/STabs/STabs.js.map +1 -1
  22. package/dist/components/STimePicker/STimePicker.cjs.map +1 -1
  23. package/dist/components/STimePicker/STimePicker.js.map +1 -1
  24. package/dist/components/STimeRangePicker/STimeRangePicker.cjs +3 -1
  25. package/dist/components/STimeRangePicker/STimeRangePicker.cjs.map +1 -1
  26. package/dist/components/STimeRangePicker/STimeRangePicker.js +3 -1
  27. package/dist/components/STimeRangePicker/STimeRangePicker.js.map +1 -1
  28. package/dist/index.cjs +261 -107
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.js +258 -108
  31. package/dist/index.js.map +1 -1
  32. package/dist/styles.css +12 -4
  33. package/dist/theme.css +1 -1
  34. package/package.json +1 -1
@@ -16,28 +16,25 @@ function cn(...inputs) {
16
16
  }
17
17
 
18
18
  // src/components/SGnb/gnb.config.ts
19
- var belt = (p) => `var(--cmp-gnbBelt-${p})`;
20
- var box = (p) => `var(--cmp-gnbBox-${p})`;
19
+ var gnb = (p) => `var(--cmp-gnb-${p})`;
20
+ var HEADER_TOP = {
21
+ topHeight: gnb("top-height"),
22
+ topPaddingX: gnb("top-paddingX"),
23
+ topGap: gnb("top-gap"),
24
+ logoHeight: gnb("logo-height")
25
+ };
21
26
  var GNB_HEADER = {
22
- fix: {
23
- topHeight: belt("top-height"),
24
- topPaddingX: belt("top-paddingX"),
25
- topGap: belt("top-gap"),
26
- logoHeight: belt("logo-height")
27
- },
28
- full: {
29
- topHeight: box("top-height"),
30
- topPaddingX: box("paddingX"),
31
- topGap: box("gap"),
32
- // 상단바 gap (버튼 간 세부 gap 은 --cmp-gnbBox-button-gap)
33
- logoHeight: box("logo-height")
34
- }
27
+ fix: HEADER_TOP,
28
+ full: HEADER_TOP
35
29
  };
30
+ var GNB_RAIL_WIDTH = 68;
31
+ var GNB_FOLD_MS = 200;
36
32
  var GNB_MENU_WIDTH = 240;
37
33
  var SLayoutContext = createContext(null);
38
34
  forwardRef(function SLayout2({
39
35
  type = "box",
40
36
  header = "fix",
37
+ useRail = false,
41
38
  folded,
42
39
  defaultFolded = false,
43
40
  onFoldedChange,
@@ -57,13 +54,17 @@ forwardRef(function SLayout2({
57
54
  [isControlled, onFoldedChange]
58
55
  );
59
56
  const ctx = useMemo(
60
- () => ({ type, header, folded: currentFolded, setFolded }),
61
- [type, header, currentFolded, setFolded]
57
+ () => ({ type, header, useRail, folded: currentFolded, setFolded }),
58
+ [type, header, useRail, currentFolded, setFolded]
62
59
  );
63
60
  const isFullHeader = header === "full";
61
+ const gnbColumnWidth = useRail ? GNB_RAIL_WIDTH + GNB_MENU_WIDTH : GNB_MENU_WIDTH;
64
62
  const frameStyle = isFullHeader ? {
65
- gridTemplateColumns: `${currentFolded ? 0 : GNB_MENU_WIDTH}px 1fr`,
66
- gridTemplateRows: `${GNB_HEADER[header].topHeight} 1fr`
63
+ gridTemplateColumns: `${currentFolded ? 0 : gnbColumnWidth}px 1fr`,
64
+ gridTemplateRows: `${GNB_HEADER[header].topHeight} 1fr`,
65
+ // 메뉴가 미끄러지는 동안 페이지 열도 같은 길이로 따라와야 한 덩어리로 읽힌다.
66
+ // (fix 는 SGnb 가 제 폭을 직접 전환하므로 프레임이 손댈 게 없다)
67
+ transition: `grid-template-columns ${GNB_FOLD_MS}ms ease-out`
67
68
  } : void 0;
68
69
  return /* @__PURE__ */ jsx(SLayoutContext.Provider, { value: ctx, children: /* @__PURE__ */ jsx(
69
70
  "div",
@@ -89,7 +90,7 @@ var SPage = forwardRef(function SPage2({ className, style, children, ...rest },
89
90
  "main",
90
91
  {
91
92
  ref,
92
- className: cn("box-border min-h-0 min-w-0 flex-1 overflow-auto p-24", className),
93
+ className: cn("box-border min-h-0 min-w-0 flex-1 overflow-auto p-20", className),
93
94
  style: { ...gridStyle, ...style },
94
95
  ...rest,
95
96
  children
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/cn.ts","../../../src/components/SGnb/gnb.config.ts","../../../src/components/SLayout/SLayout.tsx","../../../src/components/SPage/SPage.tsx"],"names":["SLayout","forwardRef","SPage","useContext","jsx"],"mappings":"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","file":"SPage.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { extendTailwindMerge } from 'tailwind-merge';\n\n/**\n * theme.css 의 --text-* 는 기본 Tailwind 폰트사이즈 스케일(xs/sm/base...)이 아닌\n * 숫자 스케일(10~32)이라, 확장 없이는 tailwind-merge 가 이를 텍스트 컬러 그룹으로\n * 오인해 text-12 같은 클래스를 text-(--color-var) 뒤에서 충돌 제거해버린다.\n */\nconst twMerge = extendTailwindMerge({\n extend: {\n classGroups: {\n 'font-size': [{ text: ['10', '11', '12', '13', '14', '16', '18', '20', '24', '28', '32'] }],\n },\n },\n});\n\n/**\n * className 병합 유틸.\n * clsx로 조건부 클래스를 합치고, tailwind-merge로 Tailwind 클래스 충돌을 해소한다.\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import type { SIconName } from '../SIcon';\nimport type { STagColor } from '../STag';\n\n/** 메뉴 스타일: box = 라운드/좁은 들여쓰기, belt = 풀폭 행/넓은 들여쓰기 */\nexport type SGnbType = 'box' | 'belt';\n/** 상단바 구조: fix = GNB 폭에 고정, full = 레이아웃 전폭 */\nexport type SGnbHeader = 'fix' | 'full';\n/** 색상(테마): light / dark */\nexport type SGnbColor = 'light' | 'dark';\n\n/** GNB 메뉴 아이템 (재귀 트리). depth1 → depth2 → depth3. */\nexport interface SGnbMenuItem {\n /** 표시 텍스트 */\n label: string;\n /** 고유 식별값 (선택 상태 비교 기준) */\n value: string;\n /** depth1 전용 아이콘명 */\n icon?: SIconName;\n /** 우측 Tag 뱃지 텍스트 */\n tag?: string;\n /** 뱃지 색. 미지정 시 blue. */\n tagColor?: STagColor;\n /** 비활성 여부 */\n disabled?: boolean;\n /** 하위 아이템 (있으면 펼침/접힘 대상) */\n children?: SGnbMenuItem[];\n}\n\n// theme.css 의 컴포넌트 토큰 참조 (디자인팀 소유, 직접 수정 금지)\n// 토큰은 gnbBox/gnbBelt 두 벌뿐이라 축 이름과 1:1 로 맞지 않는다:\n// 상단바(header 축) fix → gnbBelt-top-*, full → gnbBox-*\n// 메뉴(type 축) box → gnbBox-*, belt → gnbBelt-*\n// 색(color 축)은 --cmp-light-* / --cmp-dark-*.\nconst belt = (p: string) => `var(--cmp-gnbBelt-${p})`;\nconst box = (p: string) => `var(--cmp-gnbBox-${p})`;\nconst light = (p: string) => `var(--cmp-light-${p})`;\nconst dark = (p: string) => `var(--cmp-dark-${p})`;\n\n/**\n * 상단바(header 축). fix → gnbBelt-top-* 토큰, full → gnbBox-* 토큰.\n * 배치까지 이 축이 정한다 — fix 는 GNB 컬럼 안, full 은 레이아웃 전폭.\n */\nexport const GNB_HEADER: Record<\n SGnbHeader,\n {\n topHeight: string;\n topPaddingX: string;\n topGap: string;\n logoHeight: string;\n }\n> = {\n fix: {\n topHeight: belt('top-height'),\n topPaddingX: belt('top-paddingX'),\n topGap: belt('top-gap'),\n logoHeight: belt('logo-height'),\n },\n full: {\n topHeight: box('top-height'),\n topPaddingX: box('paddingX'),\n topGap: box('gap'), // 상단바 gap (버튼 간 세부 gap 은 --cmp-gnbBox-button-gap)\n logoHeight: box('logo-height'),\n },\n};\n\n/** 메뉴(type 축). box → --cmp-gnbBox-*, belt → --cmp-gnbBelt-* */\nexport const GNB_MENU: Record<\n SGnbType,\n {\n bodyPaddingY: string;\n itemMarginX: string;\n itemPaddingY: string;\n itemPaddingRight: string;\n itemGap: string;\n itemRadius: string;\n itemArrow: string;\n depth1PaddingLeft: string;\n depth2PaddingLeft: string;\n depth3PaddingLeft: string;\n depth1Icon: string;\n // 타이포 (theme.css 에 gnb 타이포 변수 없음 → 토큰 값 직접 명시)\n depth1FontSize: number;\n depth1LineHeight: number;\n depth1FontWeight: number;\n itemFontSize: number;\n itemLineHeight: number;\n }\n> = {\n box: {\n // menu.body.padding 은 전방향 토큰 → 세로 패딩과 아이템 좌우 여백 양쪽에 쓴다\n bodyPaddingY: box('menu-body-padding'),\n itemMarginX: box('menu-body-padding'),\n itemPaddingY: box('item-paddingY'),\n itemPaddingRight: box('item-paddingRight'),\n itemGap: box('item-gap'),\n itemRadius: box('item-radius'),\n itemArrow: box('item-arrow'),\n depth1PaddingLeft: box('item-depth1-paddingLeft'),\n depth2PaddingLeft: box('item-depth2-paddingLeft'),\n depth3PaddingLeft: box('item-depth3-paddingLeft'),\n depth1Icon: box('item-depth1-icon'),\n depth1FontSize: 14, // token: gnbBox.item.typography.bold.fontSize\n depth1LineHeight: 24, // token: gnbBox.item.typography.bold.lineHeight\n depth1FontWeight: 700,\n itemFontSize: 14, // token: gnbBox.item.typography.default.fontSize\n itemLineHeight: 24, // token: gnbBox.item.typography.default.lineHeight\n },\n belt: {\n bodyPaddingY: belt('body-paddingY'),\n itemMarginX: '0px', // belt 토큰에 가로 여백 없음 → 풀폭 행 (들여쓰기는 depth*-paddingLeft 전담)\n itemPaddingY: belt('item-paddingY'),\n itemPaddingRight: belt('item-paddingRight'),\n itemGap: belt('item-gap'),\n itemRadius: '0px', // belt 토큰에 radius 없음 → 풀폭 행\n itemArrow: belt('item-icon'),\n depth1PaddingLeft: belt('item-depth1-paddingLeft'),\n depth2PaddingLeft: belt('item-depth2-paddingLeft'),\n depth3PaddingLeft: belt('item-depth3-paddingLeft'),\n depth1Icon: box('item-depth1-icon'), // belt 토큰에 depth1-icon 없음 → box 값(20) 공유\n depth1FontSize: 13, // token: gnbBelt.item.depth1.typography.fontSize\n depth1LineHeight: 22, // token: gnbBelt.item.depth1.typography.lineHeight\n depth1FontWeight: 700,\n itemFontSize: 12, // token: gnbBelt.item.typography.default.fontSize\n itemLineHeight: 20, // token: gnbBelt.item.typography.default.lineHeight\n },\n};\n\n/**\n * 색상(color 축). light → --cmp-light-* 색, dark → --cmp-dark-* 색 (gnb colortype 토큰)\n * 상단바 아이콘 색(--cmp-*-top-icon)은 SGhostButton 이 자체 intent 토큰으로 칠한다:\n * light → --cmp-ghostButton-default-content, dark → --cmp-ghostButton-inverse-content (값 동일).\n */\nexport const GNB_COLORS: Record<\n SGnbColor,\n {\n panelBg: string;\n topBorder: string;\n itemBgDefault: string;\n itemBgHover: string;\n itemBgSelected: string;\n itemColorDefault: string;\n itemColorHover: string;\n itemColorActive: string;\n itemChevron: string;\n }\n> = {\n light: {\n panelBg: light('bg'),\n topBorder: light('divider'),\n itemBgDefault: light('item-bg-default'),\n itemBgHover: light('item-bg-hover'),\n itemBgSelected: light('item-bg-selected'),\n itemColorDefault: light('item-fg-default'),\n itemColorHover: light('item-fg-hover'),\n itemColorActive: light('item-fg-selected'),\n itemChevron: light('item-chevron'),\n },\n dark: {\n panelBg: dark('bg'),\n topBorder: dark('divider'),\n itemBgDefault: dark('item-bg-default'),\n itemBgHover: dark('item-bg-hover'),\n itemBgSelected: dark('item-bg-selected'),\n itemColorDefault: dark('item-fg-default'),\n itemColorHover: dark('item-fg-hover'),\n itemColorActive: dark('item-fg-selected'),\n itemChevron: dark('item-chevron'),\n },\n};\n\n/** 축과 무관한 공통 레이아웃. */\nexport const GNB_COMMON = {\n /** 접힘 레일 폭. header=\"full\" 은 접으면 메뉴만 사라져 레일이 없으므로 fix 전용. */\n foldWidth: belt('fold-width'),\n /**\n * 상단바 아이콘 버튼 사이 간격. fix 레일(세로 스택)의 gap·상하 여백에 쓴다.\n * 레일 전용 여백 토큰이 따로 없어 같은 값(8px)인 이 토큰으로 재해석했다.\n */\n buttonGap: box('button-gap'),\n};\n\n/**\n * 메뉴 고정 폭. 두 header 모두 메뉴는 이 폭으로 좌측에 붙는다(토큰 없음 → 상수).\n * header=\"full\" 에서 SLayout 이 \"메뉴 오른쪽 = 페이지\" 영역을 잡을 때도 이 값을 쓴다.\n */\nexport const GNB_MENU_WIDTH = 240;\n\n/** value 로 선택된 아이템의 조상 value 목록(자기 자신 제외)을 반환 → 자동 펼침용 */\nexport function findGnbAncestors(items: SGnbMenuItem[], value: string): string[] {\n const walk = (list: SGnbMenuItem[], trail: string[]): string[] | null => {\n for (const item of list) {\n if (item.value === value) return trail;\n if (item.children) {\n const found = walk(item.children, [...trail, item.value]);\n if (found) return found;\n }\n }\n return null;\n };\n return walk(items, []) ?? [];\n}\n","'use client';\n\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n useState,\n type CSSProperties,\n type HTMLAttributes,\n} from 'react';\nimport { cn } from '../../lib/cn';\nimport { GNB_HEADER, GNB_MENU_WIDTH, type SGnbHeader, type SGnbType } from '../SGnb/gnb.config';\n\n/** 메뉴 스타일: box(라운드) / belt(풀폭 행) — 자식 SGnb 의 메뉴 모양 */\nexport type SLayoutType = SGnbType;\n/** 레이아웃 구조: fix(좌측 GNB + 페이지 가로 분할) / full(풀폭 상단바 + 아래에 메뉴|페이지) */\nexport type SLayoutHeader = SGnbHeader;\n\nexport interface SLayoutContextValue {\n /** 메뉴 스타일 — 자식 SGnb 가 스타일을 맞추는 기준 */\n type: SLayoutType;\n /** 상단바 구조 — 자식 SGnb·SPage 가 배치를 맞추는 기준 */\n header: SLayoutHeader;\n /** GNB 접힘 상태 */\n folded: boolean;\n /** 접힘 상태 변경 */\n setFolded: (folded: boolean) => void;\n}\n\nexport const SLayoutContext = createContext<SLayoutContextValue | null>(null);\n\n/**\n * SLayout 의 상태를 읽고 제어한다. `<SLayout>` 밖에서 호출하면 throw.\n * 외부 버튼으로 GNB 를 접거나, 현재 구조(header)·메뉴 스타일(type)에 따라 분기할 때 사용한다.\n */\nexport function useSLayout(): SLayoutContextValue {\n const ctx = useContext(SLayoutContext);\n if (!ctx) throw new Error('useSLayout 은 <SLayout> 안에서만 사용할 수 있습니다.');\n return ctx;\n}\n\nexport interface SLayoutProps extends HTMLAttributes<HTMLDivElement> {\n /** 메뉴 스타일: box(라운드) / belt(풀폭 행). 자식 SGnb 가 이 값을 따른다. */\n type?: SLayoutType;\n /** 레이아웃 구조: fix(가로 분할) / full(풀폭 상단바). 자식 SGnb·SPage 가 이 값을 따른다. */\n header?: SLayoutHeader;\n /** GNB 접힘 상태 (controlled) */\n folded?: boolean;\n /** GNB 초기 접힘 상태 (uncontrolled) */\n defaultFolded?: boolean;\n /** 접힘 상태 변경 */\n onFoldedChange?: (folded: boolean) => void;\n}\n\n/**\n * SLayout — 앱 셸 프레임 겸 context provider.\n * header 가 배치를 결정하고 overflow:hidden 으로 스크롤 경계를 만든다.\n * 자식 SGnb·SPage 는 context 에서 type·header·folded 를 읽으므로 앱은 이 값들을 여기 한 곳에만 쓴다.\n * 높이는 기본 100%(부모를 채움) — 앱 루트로 쓸 땐 부모를 뷰포트 높이로 잡는다.\n *\n * - header=\"fix\": [GNB(자기 폭 소유) | 페이지] 가로 flex.\n * - header=\"full\": 상단바가 풀폭이고 그 아래에 [메뉴 | 페이지] 가 나란히 서야 하는데, SGnb 가 상단바와\n * 메뉴를 한 엘리먼트로 갖고 있어 사이에 끼워넣을 수 없다. 그래서 grid 로 깔고\n * SGnb 를 전 셀에 겹친 뒤(root 는 투명) SPage 가 (2행,2열) = \"메뉴 오른쪽 투명 영역\" 을 차지한다.\n * 접으면 메뉴가 사라지므로 1열을 0 으로 접어 페이지가 전폭을 쓴다.\n */\nexport const SLayout = forwardRef<HTMLDivElement, SLayoutProps>(function SLayout(\n {\n type = 'box',\n header = 'fix',\n folded,\n defaultFolded = false,\n onFoldedChange,\n className,\n style,\n children,\n ...rest\n },\n ref,\n) {\n const [uncontrolledFolded, setUncontrolledFolded] = useState(defaultFolded);\n const isControlled = folded !== undefined;\n const currentFolded = isControlled ? folded : uncontrolledFolded;\n\n const setFolded = useCallback(\n (next: boolean) => {\n if (!isControlled) setUncontrolledFolded(next);\n onFoldedChange?.(next);\n },\n [isControlled, onFoldedChange],\n );\n\n const ctx = useMemo<SLayoutContextValue>(\n () => ({ type, header, folded: currentFolded, setFolded }),\n [type, header, currentFolded, setFolded],\n );\n\n const isFullHeader = header === 'full';\n const frameStyle: CSSProperties | undefined = isFullHeader\n ? {\n gridTemplateColumns: `${currentFolded ? 0 : GNB_MENU_WIDTH}px 1fr`,\n gridTemplateRows: `${GNB_HEADER[header].topHeight} 1fr`,\n }\n : undefined;\n\n return (\n <SLayoutContext.Provider value={ctx}>\n <div\n ref={ref}\n data-type={type}\n data-header={header}\n className={cn(\n 'box-border h-full overflow-hidden',\n isFullHeader ? 'grid' : 'flex flex-row',\n className,\n )}\n style={{ ...frameStyle, ...style }}\n {...rest}\n >\n {children}\n </div>\n </SLayoutContext.Provider>\n );\n});\n","import { forwardRef, useContext, type CSSProperties, type HTMLAttributes } from 'react';\nimport { cn } from '../../lib/cn';\nimport { SLayoutContext } from '../SLayout/SLayout';\n\n// 자체 prop 없이 표준 HTML 속성만 받는다.\n// docs:gen 은 export interface 만 수집하므로(gen-docs.mjs) 타입 별칭 대신 interface 로 둔다.\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport interface SPageProps extends HTMLAttributes<HTMLElement> {}\n\n/**\n * SPage — SLayout 안에서 남은 공간을 채우는 스크롤 콘텐츠 영역.\n * 스크롤은 SLayout(overflow:hidden)이 잡아둔 경계 안에서 이 영역에만 생긴다.\n * 패딩·배경은 className 으로 덮어쓸 수 있다.\n *\n * - header=\"fix\": flex 자식으로 남은 가로 공간을 채운다.\n * - header=\"full\": SLayout 이 깐 grid 의 (2행,2열) = 상단바 아래·메뉴 오른쪽 자리를 차지한다.\n * (SGnb 가 전 셀에 겹쳐 있지만 그 영역은 투명해서 SPage 가 보인다)\n */\nexport const SPage = forwardRef<HTMLElement, SPageProps>(function SPage(\n { className, style, children, ...rest },\n ref,\n) {\n const layout = useContext(SLayoutContext);\n const gridStyle: CSSProperties | undefined =\n layout?.header === 'full' ? { gridColumn: 2, gridRow: 2 } : undefined;\n\n return (\n <main\n ref={ref}\n className={cn('box-border min-h-0 min-w-0 flex-1 overflow-auto p-24', className)}\n style={{ ...gridStyle, ...style }}\n {...rest}\n >\n {children}\n </main>\n );\n});\n"]}
1
+ {"version":3,"sources":["../../../src/lib/cn.ts","../../../src/components/SGnb/gnb.config.ts","../../../src/components/SLayout/SLayout.tsx","../../../src/components/SPage/SPage.tsx"],"names":["SLayout","forwardRef","SPage","useContext","jsx"],"mappings":";;;;;;AAQA,IAAM,UAAU,mBAAA,CAAoB;AAAA,EAClC,MAAA,EAAQ;AAAA,IACN,WAAA,EAAa;AAAA,MACX,aAAa,CAAC,EAAE,IAAA,EAAM,CAAC,MAAM,IAAA,EAAM,IAAA,EAAM,IAAA,EAAM,IAAA,EAAM,MAAM,IAAA,EAAM,IAAA,EAAM,MAAM,IAAA,EAAM,IAAI,GAAG;AAAA;AAC5F;AAEJ,CAAC,CAAA;AAMM,SAAS,MAAM,MAAA,EAA8B;AAClD,EAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAM,CAAC,CAAA;AAC7B;;;ACaA,IAAM,GAAA,GAAM,CAAC,CAAA,KAAc,CAAA,cAAA,EAAiB,CAAC,CAAA,CAAA,CAAA;AAU7C,IAAM,UAAA,GAAa;AAAA,EACjB,SAAA,EAAW,IAAI,YAAY,CAAA;AAAA,EAC3B,WAAA,EAAa,IAAI,cAAc,CAAA;AAAA,EAC/B,MAAA,EAAQ,IAAI,SAAS,CAAA;AAAA,EACrB,UAAA,EAAY,IAAI,aAAa;AAC/B,CAAA;AAEO,IAAM,UAAA,GAQT;AAAA,EACF,GAAA,EAAK,UAAA;AAAA,EACL,IAAA,EAAM;AACR,CAAA;AAgJO,IAAM,cAAA,GAAiB,EAAA;AAMvB,IAAM,WAAA,GAAc,GAAA;AAapB,IAAM,cAAA,GAAiB,GAAA;AC1LvB,IAAM,cAAA,GAAiB,cAA0C,IAAI,CAAA;AAuCrD,UAAA,CAAyC,SAASA,QAAAA,CACvE;AAAA,EACE,IAAA,GAAO,KAAA;AAAA,EACP,MAAA,GAAS,KAAA;AAAA,EACT,OAAA,GAAU,KAAA;AAAA,EACV,MAAA;AAAA,EACA,aAAA,GAAgB,KAAA;AAAA,EAChB,cAAA;AAAA,EACA,SAAA;AAAA,EACA,KAAA;AAAA,EACA,QAAA;AAAA,EACA,GAAG;AACL,CAAA,EACA,GAAA,EACA;AACA,EAAA,MAAM,CAAC,kBAAA,EAAoB,qBAAqB,CAAA,GAAI,SAAS,aAAa,CAAA;AAC1E,EAAA,MAAM,eAAe,MAAA,KAAW,MAAA;AAChC,EAAA,MAAM,aAAA,GAAgB,eAAe,MAAA,GAAS,kBAAA;AAE9C,EAAA,MAAM,SAAA,GAAY,WAAA;AAAA,IAChB,CAAC,IAAA,KAAkB;AACjB,MAAA,IAAI,CAAC,YAAA,EAAc,qBAAA,CAAsB,IAAI,CAAA;AAC7C,MAAA,cAAA,GAAiB,IAAI,CAAA;AAAA,IACvB,CAAA;AAAA,IACA,CAAC,cAAc,cAAc;AAAA,GAC/B;AAEA,EAAA,MAAM,GAAA,GAAM,OAAA;AAAA,IACV,OAAO,EAAE,IAAA,EAAM,QAAQ,OAAA,EAAS,MAAA,EAAQ,eAAe,SAAA,EAAU,CAAA;AAAA,IACjE,CAAC,IAAA,EAAM,MAAA,EAAQ,OAAA,EAAS,eAAe,SAAS;AAAA,GAClD;AAEA,EAAA,MAAM,eAAe,MAAA,KAAW,MAAA;AAEhC,EAAA,MAAM,cAAA,GAAiB,OAAA,GAAU,cAAA,GAAiB,cAAA,GAAiB,cAAA;AACnE,EAAA,MAAM,aAAwC,YAAA,GAC1C;AAAA,IACE,mBAAA,EAAqB,CAAA,EAAG,aAAA,GAAgB,CAAA,GAAI,cAAc,CAAA,MAAA,CAAA;AAAA,IAC1D,gBAAA,EAAkB,CAAA,EAAG,UAAA,CAAW,MAAM,EAAE,SAAS,CAAA,IAAA,CAAA;AAAA;AAAA;AAAA,IAGjD,UAAA,EAAY,yBAAyB,WAAW,CAAA,WAAA;AAAA,GAClD,GACA,MAAA;AAEJ,EAAA,uBACE,GAAA,CAAC,cAAA,CAAe,QAAA,EAAf,EAAwB,OAAO,GAAA,EAC9B,QAAA,kBAAA,GAAA;AAAA,IAAC,KAAA;AAAA,IAAA;AAAA,MACC,GAAA;AAAA,MACA,WAAA,EAAW,IAAA;AAAA,MACX,aAAA,EAAa,MAAA;AAAA,MACb,SAAA,EAAW,EAAA;AAAA,QACT,mCAAA;AAAA,QACA,eAAe,MAAA,GAAS,eAAA;AAAA,QACxB;AAAA,OACF;AAAA,MACA,KAAA,EAAO,EAAE,GAAG,UAAA,EAAY,GAAG,KAAA,EAAM;AAAA,MAChC,GAAG,IAAA;AAAA,MAEH;AAAA;AAAA,GACH,EACF,CAAA;AAEJ,CAAC;AC5HM,IAAM,KAAA,GAAQC,UAAAA,CAAoC,SAASC,MAAAA,CAChE,EAAE,SAAA,EAAW,KAAA,EAAO,QAAA,EAAU,GAAG,IAAA,EAAK,EACtC,GAAA,EACA;AACA,EAAA,MAAM,MAAA,GAASC,WAAW,cAAc,CAAA;AACxC,EAAA,MAAM,SAAA,GACJ,QAAQ,MAAA,KAAW,MAAA,GAAS,EAAE,UAAA,EAAY,CAAA,EAAG,OAAA,EAAS,CAAA,EAAE,GAAI,MAAA;AAE9D,EAAA,uBACEC,GAAAA;AAAA,IAAC,MAAA;AAAA,IAAA;AAAA,MACC,GAAA;AAAA,MACA,SAAA,EAAW,EAAA,CAAG,sDAAA,EAAwD,SAAS,CAAA;AAAA,MAC/E,KAAA,EAAO,EAAE,GAAG,SAAA,EAAW,GAAG,KAAA,EAAM;AAAA,MAC/B,GAAG,IAAA;AAAA,MAEH;AAAA;AAAA,GACH;AAEJ,CAAC","file":"SPage.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { extendTailwindMerge } from 'tailwind-merge';\n\n/**\n * theme.css 의 --text-* 는 기본 Tailwind 폰트사이즈 스케일(xs/sm/base...)이 아닌\n * 숫자 스케일(10~32)이라, 확장 없이는 tailwind-merge 가 이를 텍스트 컬러 그룹으로\n * 오인해 text-12 같은 클래스를 text-(--color-var) 뒤에서 충돌 제거해버린다.\n */\nconst twMerge = extendTailwindMerge({\n extend: {\n classGroups: {\n 'font-size': [{ text: ['10', '11', '12', '13', '14', '16', '18', '20', '24', '28', '32'] }],\n },\n },\n});\n\n/**\n * className 병합 유틸.\n * clsx로 조건부 클래스를 합치고, tailwind-merge로 Tailwind 클래스 충돌을 해소한다.\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import type { SColor } from '../../lib/color';\nimport type { SIconName } from '../SIcon';\nimport type { STagColor } from '../STag';\n\n/** 메뉴 스타일: box = 라운드/좁은 들여쓰기, belt = 풀폭 행/넓은 들여쓰기 */\nexport type SGnbType = 'box' | 'belt';\n/** 상단바 구조: fix = GNB 폭에 고정, full = 레이아웃 전폭 */\nexport type SGnbHeader = 'fix' | 'full';\n/** 색상(테마): light / dark */\nexport type SGnbColor = 'light' | 'dark';\n\n/** GNB 메뉴 아이템 (재귀 트리). depth1 → depth2 → depth3. */\nexport interface SGnbMenuItem {\n /** 표시 텍스트 */\n label: string;\n /** 고유 식별값 (선택 상태 비교 기준) */\n value: string;\n /** depth1 전용 아이콘명 */\n icon?: SIconName;\n /** 우측 Tag 뱃지 텍스트 */\n tag?: string;\n /** 뱃지 색. 미지정 시 blue. */\n tagColor?: STagColor;\n /** 비활성 여부 */\n disabled?: boolean;\n /** 하위 아이템 (있으면 펼침/접힘 대상) */\n children?: SGnbMenuItem[];\n}\n\n// theme.css 의 컴포넌트 토큰 참조 (디자인팀 소유, 직접 수정 금지)\n// 토큰은 gnb 한 벌로 통합됐다:\n// 공통(상단바/로고/폴드/바디/아이템) → --cmp-gnb-*\n// 메뉴 변형(type 축) box → --cmp-gnb-box-*, belt → --cmp-gnb-belt-*\n// 접힘 레일 → --cmp-gnb-rail-*\n// 색(color 축) light → --cmp-gnb-light-*, dark → --cmp-gnb-dark-*\nconst gnb = (p: string) => `var(--cmp-gnb-${p})`;\nconst box = (p: string) => `var(--cmp-gnb-box-${p})`;\nconst belt = (p: string) => `var(--cmp-gnb-belt-${p})`;\nconst light = (p: string) => `var(--cmp-gnb-light-${p})`;\nconst dark = (p: string) => `var(--cmp-gnb-dark-${p})`;\n\n/**\n * 상단바(header 축). 상단바 치수 토큰은 gnb 로 통합돼(--cmp-gnb-top-*) fix·full 이 공유한다.\n * 배치는 여전히 이 축이 정한다 — fix 는 GNB 컬럼 안, full 은 레이아웃 전폭(SGnb 에서 분기).\n */\nconst HEADER_TOP = {\n topHeight: gnb('top-height'),\n topPaddingX: gnb('top-paddingX'),\n topGap: gnb('top-gap'),\n logoHeight: gnb('logo-height'),\n};\n\nexport const GNB_HEADER: Record<\n SGnbHeader,\n {\n topHeight: string;\n topPaddingX: string;\n topGap: string;\n logoHeight: string;\n }\n> = {\n fix: HEADER_TOP,\n full: HEADER_TOP,\n};\n\n/** 메뉴(type 축). box → --cmp-gnb-box-*, belt → --cmp-gnb-belt-* (공통 치수는 --cmp-gnb-*) */\nexport const GNB_MENU: Record<\n SGnbType,\n {\n bodyPaddingY: string;\n itemMarginX: string;\n itemPaddingY: string;\n itemPaddingRight: string;\n itemGap: string;\n itemRadius: string;\n itemArrow: string;\n depth1PaddingLeft: string;\n depth2PaddingLeft: string;\n depth3PaddingLeft: string;\n depth1Icon: string;\n // 타이포 (theme.css 에 gnb 타이포 변수 없음 → 토큰 값 직접 명시)\n depth1FontSize: number;\n depth1LineHeight: number;\n depth1FontWeight: number;\n itemFontSize: number;\n itemLineHeight: number;\n }\n> = {\n box: {\n // 바디 패딩·세로 패딩·chevron 은 gnb 로 통합된 공통 토큰\n bodyPaddingY: gnb('body-padding'),\n itemMarginX: gnb('body-padding'), // 전방향 토큰 → 아이템 좌우 여백에도 사용\n itemPaddingY: gnb('item-paddingY'),\n itemPaddingRight: box('item-paddingRight'),\n itemGap: box('item-gap'),\n itemRadius: box('item-radius'),\n itemArrow: gnb('item-chevron-size'),\n depth1PaddingLeft: box('item-depth1-paddingLeft'),\n depth2PaddingLeft: box('item-depth2-paddingLeft'),\n depth3PaddingLeft: box('item-depth3-paddingLeft'),\n depth1Icon: box('item-depth1-icon'),\n depth1FontSize: 14, // token: gnb.box.item.typography.selected.fontSize\n depth1LineHeight: 24, // token: gnb.box.item.typography.selected.lineHeight\n depth1FontWeight: 700,\n itemFontSize: 14, // token: gnb.box.item.typography.default.fontSize\n itemLineHeight: 24, // token: gnb.box.item.typography.default.lineHeight\n },\n belt: {\n bodyPaddingY: gnb('body-padding'),\n itemMarginX: '0px', // belt 토큰에 가로 여백 없음 → 풀폭 행 (들여쓰기는 depth*-paddingLeft 전담)\n itemPaddingY: gnb('item-paddingY'),\n itemPaddingRight: belt('item-paddingRight'),\n itemGap: belt('item-gap'),\n itemRadius: '0px', // belt 토큰에 radius 없음 → 풀폭 행\n itemArrow: gnb('item-chevron-size'),\n depth1PaddingLeft: belt('item-depth1-paddingLeft'),\n depth2PaddingLeft: belt('item-depth2-paddingLeft'),\n depth3PaddingLeft: belt('item-depth3-paddingLeft'),\n depth1Icon: belt('item-depth1-icon'), // belt 전용 depth1 아이콘 토큰(16)\n depth1FontSize: 13, // token: gnb.belt.item.depth1.typography.fontSize\n depth1LineHeight: 22, // token: gnb.belt.item.depth1.typography.lineHeight\n depth1FontWeight: 700,\n itemFontSize: 12, // token: gnb.belt.item.typography.default.fontSize\n itemLineHeight: 20, // token: gnb.belt.item.typography.default.lineHeight\n },\n};\n\n/**\n * 색상(color 축). light → --cmp-gnb-light-* 색, dark → --cmp-gnb-dark-* 색\n * 상단바 아이콘 색(--cmp-gnb-*-top-icon)은 SGhostButton 이 자체 intent 토큰으로 칠한다:\n * light → --cmp-ghostButton-default-content, dark → --cmp-ghostButton-inverse-content (값 동일).\n */\nexport const GNB_COLORS: Record<\n SGnbColor,\n {\n panelBg: string;\n topBorder: string;\n itemBgDefault: string;\n itemBgHover: string;\n itemBgSelected: string;\n itemColorDefault: string;\n itemColorHover: string;\n itemColorActive: string;\n itemChevron: string;\n /** depth1 아이콘 색. 대응 --cmp-gnb-*-item-icon 토큰이 없어 팔레트 키로 직접 지정한다. */\n itemIcon: SColor;\n /** 레일 아이템 기본 색(아이콘+라벨). 선택 시엔 itemColorActive 를 쓴다. */\n railFgDefault: string;\n }\n> = {\n light: {\n panelBg: light('bg'),\n topBorder: light('divider'),\n itemBgDefault: light('item-bg-default'),\n itemBgHover: light('item-bg-hover'),\n itemBgSelected: light('item-bg-selected'),\n itemColorDefault: light('item-fg-default'),\n itemColorHover: light('item-fg-hover'),\n itemColorActive: light('item-fg-selected'),\n itemChevron: light('item-chevron'),\n itemIcon: 'grey_65',\n railFgDefault: light('rail-fg-default'),\n },\n dark: {\n panelBg: dark('bg'),\n topBorder: dark('divider'),\n itemBgDefault: dark('item-bg-default'),\n itemBgHover: dark('item-bg-hover'),\n itemBgSelected: dark('item-bg-selected'),\n itemColorDefault: dark('item-fg-default'),\n itemColorHover: dark('item-fg-hover'),\n itemColorActive: dark('item-fg-selected'),\n itemChevron: dark('item-chevron'),\n itemIcon: 'grey_55',\n railFgDefault: dark('rail-fg-default'),\n },\n};\n\n/** 축과 무관한 공통 레이아웃. */\nexport const GNB_COMMON = {\n /** 접힘 레일 폭. header=\"full\" 은 접으면 메뉴만 사라져 레일이 없으므로 fix 전용. */\n foldWidth: gnb('fold-width'),\n};\n\n/**\n * 좌측 레일(useRail) — items 의 첫 depth 를 아이콘+라벨 버튼으로 세우는 1차 내비게이션.\n * 선택 색은 레일 전용 토큰이 없어 메뉴 아이템의 선택 색(item-bg-selected / item-fg-selected)을 함께 쓴다.\n */\nexport const GNB_RAIL = {\n padding: gnb('rail-padding'),\n gap: gnb('rail-gap'),\n itemWidth: gnb('rail-item-width'),\n itemPaddingTop: gnb('rail-item-paddingTop'),\n itemPaddingBottom: gnb('rail-item-paddingBottom'),\n itemRadius: gnb('rail-item-radius'),\n itemIcon: gnb('rail-item-icon'),\n // 타이포 (theme.css 에 gnb 타이포 변수 없음 → 토큰 값 직접 명시)\n itemFontSize: 11, // token: gnb.rail.item.typography.default.fontSize\n itemLineHeight: 18, // token: gnb.rail.item.typography.default.lineHeight\n itemFontWeight: 500, // token: gnb.rail.item.typography.default.fontWeight\n itemFontWeightSelected: 700, // token: gnb.rail.item.typography.selected.fontWeight\n};\n\n/**\n * 레일 컬럼 폭 = rail-item-width(44) + rail-padding(12)*2.\n * 토큰이 CSS 변수라 JS 에서 더할 수 없어 합을 상수로 둔다 (GNB_MENU_WIDTH 와 같은 이유).\n */\nexport const GNB_RAIL_WIDTH = 68;\n\n/**\n * 접힘/펼침 전환 시간(ms). GNB 폭·메뉴 슬라이드·상단바 페이드와 SLayout 의 메뉴 열 폭이\n * 이 값을 공유해야 한 덩어리로 미끄러진다.\n */\nexport const GNB_FOLD_MS = 200;\n\n/**\n * 하위 메뉴 펼침/접힘 전환 시간(ms). 아이템을 눌러 children 이 열리고 닫힐 때\n * 높이(grid-template-rows 0fr↔1fr)가 이 길이만큼 부드럽게 늘고 줄어든다.\n * 폴드와 같은 값을 써서 두 모션이 한 언어로 읽히게 한다.\n */\nexport const GNB_EXPAND_MS = 200;\n\n/**\n * 메뉴 고정 폭. 두 header 모두 메뉴는 이 폭으로 좌측에 붙는다(토큰 없음 → 상수).\n * header=\"full\" 에서 SLayout 이 \"메뉴 오른쪽 = 페이지\" 영역을 잡을 때도 이 값을 쓴다.\n */\nexport const GNB_MENU_WIDTH = 240;\n\n/**\n * useRail 에서 value 를 품은 첫 depth(레일) 아이템의 value.\n * value 자체가 레일 아이템이면 그 값을, 어디에도 없으면 undefined 를 반환한다.\n */\nexport function findGnbRailValue(items: SGnbMenuItem[], value: string): string | undefined {\n if (items.some(item => item.value === value)) return value;\n return findGnbAncestors(items, value)[0];\n}\n\n/** value 로 선택된 아이템의 조상 value 목록(자기 자신 제외)을 반환 → 자동 펼침용 */\nexport function findGnbAncestors(items: SGnbMenuItem[], value: string): string[] {\n const walk = (list: SGnbMenuItem[], trail: string[]): string[] | null => {\n for (const item of list) {\n if (item.value === value) return trail;\n if (item.children) {\n const found = walk(item.children, [...trail, item.value]);\n if (found) return found;\n }\n }\n return null;\n };\n return walk(items, []) ?? [];\n}\n","'use client';\n\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n useState,\n type CSSProperties,\n type HTMLAttributes,\n} from 'react';\nimport { cn } from '../../lib/cn';\nimport {\n GNB_FOLD_MS,\n GNB_HEADER,\n GNB_MENU_WIDTH,\n GNB_RAIL_WIDTH,\n type SGnbHeader,\n type SGnbType,\n} from '../SGnb/gnb.config';\n\n/** 메뉴 스타일: box(라운드) / belt(풀폭 행) — 자식 SGnb 의 메뉴 모양 */\nexport type SLayoutType = SGnbType;\n/** 레이아웃 구조: fix(좌측 GNB + 페이지 가로 분할) / full(풀폭 상단바 + 아래에 메뉴|페이지) */\nexport type SLayoutHeader = SGnbHeader;\n\nexport interface SLayoutContextValue {\n /** 메뉴 스타일 — 자식 SGnb 가 스타일을 맞추는 기준 */\n type: SLayoutType;\n /** 상단바 구조 — 자식 SGnb·SPage 가 배치를 맞추는 기준 */\n header: SLayoutHeader;\n /** GNB 좌측 레일 사용 여부 — 자식 SGnb 가 따르고, header=\"full\" 의 메뉴 열 폭도 이 값을 반영한다 */\n useRail: boolean;\n /** GNB 접힘 상태 */\n folded: boolean;\n /** 접힘 상태 변경 */\n setFolded: (folded: boolean) => void;\n}\n\nexport const SLayoutContext = createContext<SLayoutContextValue | null>(null);\n\n/**\n * SLayout 의 상태를 읽고 제어한다. `<SLayout>` 밖에서 호출하면 throw.\n * 외부 버튼으로 GNB 를 접거나, 현재 구조(header)·메뉴 스타일(type)에 따라 분기할 때 사용한다.\n */\nexport function useSLayout(): SLayoutContextValue {\n const ctx = useContext(SLayoutContext);\n if (!ctx) throw new Error('useSLayout 은 <SLayout> 안에서만 사용할 수 있습니다.');\n return ctx;\n}\n\nexport interface SLayoutProps extends HTMLAttributes<HTMLDivElement> {\n /** 메뉴 스타일: box(라운드) / belt(풀폭 행). 자식 SGnb 가 이 값을 따른다. */\n type?: SLayoutType;\n /** 레이아웃 구조: fix(가로 분할) / full(풀폭 상단바). 자식 SGnb·SPage 가 이 값을 따른다. */\n header?: SLayoutHeader;\n /** GNB 좌측 레일 사용 여부. 자식 SGnb 가 이 값을 따르고, full 의 메뉴 열 폭도 레일만큼 넓어진다. */\n useRail?: boolean;\n /** GNB 접힘 상태 (controlled) */\n folded?: boolean;\n /** GNB 초기 접힘 상태 (uncontrolled) */\n defaultFolded?: boolean;\n /** 접힘 상태 변경 */\n onFoldedChange?: (folded: boolean) => void;\n}\n\n/**\n * SLayout — 앱 셸 프레임 겸 context provider.\n * header 가 배치를 결정하고 overflow:hidden 으로 스크롤 경계를 만든다.\n * 자식 SGnb·SPage 는 context 에서 type·header·folded 를 읽으므로 앱은 이 값들을 여기 한 곳에만 쓴다.\n * 높이는 기본 100%(부모를 채움) — 앱 루트로 쓸 땐 부모를 뷰포트 높이로 잡는다.\n *\n * - header=\"fix\": [GNB(자기 폭 소유) | 페이지] 가로 flex.\n * - header=\"full\": 상단바가 풀폭이고 그 아래에 [메뉴 | 페이지] 가 나란히 서야 하는데, SGnb 가 상단바와\n * 메뉴를 한 엘리먼트로 갖고 있어 사이에 끼워넣을 수 없다. 그래서 grid 로 깔고\n * SGnb 를 전 셀에 겹친 뒤(root 는 투명) SPage 가 (2행,2열) = \"메뉴 오른쪽 투명 영역\" 을 차지한다.\n * 접으면 메뉴가 사라지므로 1열을 0 으로 접어 페이지가 전폭을 쓴다.\n */\nexport const SLayout = forwardRef<HTMLDivElement, SLayoutProps>(function SLayout(\n {\n type = 'box',\n header = 'fix',\n useRail = false,\n folded,\n defaultFolded = false,\n onFoldedChange,\n className,\n style,\n children,\n ...rest\n },\n ref,\n) {\n const [uncontrolledFolded, setUncontrolledFolded] = useState(defaultFolded);\n const isControlled = folded !== undefined;\n const currentFolded = isControlled ? folded : uncontrolledFolded;\n\n const setFolded = useCallback(\n (next: boolean) => {\n if (!isControlled) setUncontrolledFolded(next);\n onFoldedChange?.(next);\n },\n [isControlled, onFoldedChange],\n );\n\n const ctx = useMemo<SLayoutContextValue>(\n () => ({ type, header, useRail, folded: currentFolded, setFolded }),\n [type, header, useRail, currentFolded, setFolded],\n );\n\n const isFullHeader = header === 'full';\n // 레일이 있으면 GNB 가 [레일 | 메뉴] 두 칸이므로 페이지는 그만큼 오른쪽에서 시작한다.\n const gnbColumnWidth = useRail ? GNB_RAIL_WIDTH + GNB_MENU_WIDTH : GNB_MENU_WIDTH;\n const frameStyle: CSSProperties | undefined = isFullHeader\n ? {\n gridTemplateColumns: `${currentFolded ? 0 : gnbColumnWidth}px 1fr`,\n gridTemplateRows: `${GNB_HEADER[header].topHeight} 1fr`,\n // 메뉴가 미끄러지는 동안 페이지 열도 같은 길이로 따라와야 한 덩어리로 읽힌다.\n // (fix 는 SGnb 가 제 폭을 직접 전환하므로 프레임이 손댈 게 없다)\n transition: `grid-template-columns ${GNB_FOLD_MS}ms ease-out`,\n }\n : undefined;\n\n return (\n <SLayoutContext.Provider value={ctx}>\n <div\n ref={ref}\n data-type={type}\n data-header={header}\n className={cn(\n 'box-border h-full overflow-hidden',\n isFullHeader ? 'grid' : 'flex flex-row',\n className,\n )}\n style={{ ...frameStyle, ...style }}\n {...rest}\n >\n {children}\n </div>\n </SLayoutContext.Provider>\n );\n});\n","import { forwardRef, useContext, type CSSProperties, type HTMLAttributes } from 'react';\nimport { cn } from '../../lib/cn';\nimport { SLayoutContext } from '../SLayout/SLayout';\n\n// 자체 prop 없이 표준 HTML 속성만 받는다.\n// docs:gen 은 export interface 만 수집하므로(gen-docs.mjs) 타입 별칭 대신 interface 로 둔다.\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\nexport interface SPageProps extends HTMLAttributes<HTMLElement> {}\n\n/**\n * SPage — SLayout 안에서 남은 공간을 채우는 스크롤 콘텐츠 영역.\n * 스크롤은 SLayout(overflow:hidden)이 잡아둔 경계 안에서 이 영역에만 생긴다.\n * 패딩·배경은 className 으로 덮어쓸 수 있다.\n *\n * - header=\"fix\": flex 자식으로 남은 가로 공간을 채운다.\n * - header=\"full\": SLayout 이 깐 grid 의 (2행,2열) = 상단바 아래·메뉴 오른쪽 자리를 차지한다.\n * (SGnb 가 전 셀에 겹쳐 있지만 그 영역은 투명해서 SPage 가 보인다)\n */\nexport const SPage = forwardRef<HTMLElement, SPageProps>(function SPage(\n { className, style, children, ...rest },\n ref,\n) {\n const layout = useContext(SLayoutContext);\n const gridStyle: CSSProperties | undefined =\n layout?.header === 'full' ? { gridColumn: 2, gridRow: 2 } : undefined;\n\n return (\n <main\n ref={ref}\n className={cn('box-border min-h-0 min-w-0 flex-1 overflow-auto p-20', className)}\n style={{ ...gridStyle, ...style }}\n {...rest}\n >\n {children}\n </main>\n );\n});\n"]}
@@ -2967,6 +2967,7 @@ function STabs({
2967
2967
  }) {
2968
2968
  const s = TAB_MAIN_SIZE[size];
2969
2969
  const blue = TAB_MAIN_COLORS.selected.border;
2970
+ const isVertical = isSub && vertical;
2970
2971
  const tabRefs = react.useRef([]);
2971
2972
  const moveTo = (target) => {
2972
2973
  if (target < 0 || target >= tabs.length) return;
@@ -2974,19 +2975,17 @@ function STabs({
2974
2975
  tabRefs.current[target]?.focus();
2975
2976
  };
2976
2977
  const containerStyle = { ...style };
2977
- if (!isSub) {
2978
- if (vertical) containerStyle.borderRight = `1px solid ${blue}`;
2979
- else containerStyle.borderBottom = `1px solid ${blue}`;
2980
- }
2978
+ if (!isSub) containerStyle.borderBottom = `1px solid ${blue}`;
2981
2979
  return /* @__PURE__ */ jsxRuntime.jsx(
2982
2980
  "div",
2983
2981
  {
2984
2982
  role: "tablist",
2985
- "aria-orientation": vertical ? "vertical" : "horizontal",
2983
+ "aria-orientation": isVertical ? "vertical" : "horizontal",
2986
2984
  className: cn(
2987
- "inline-flex w-fit",
2988
- vertical ? "flex-col items-stretch" : "flex-row",
2989
- isSub ? vertical ? "gap-[16px]" : "gap-[24px]" : "gap-[4px]",
2985
+ // main(가로): baseline(하단 divider)이 100% 너비로 채워지도록 w-full
2986
+ isSub ? "inline-flex w-fit" : "flex w-full",
2987
+ isVertical ? "flex-col items-stretch" : "flex-row",
2988
+ isSub ? isVertical ? "gap-[16px]" : "gap-[24px]" : "gap-[4px]",
2990
2989
  className
2991
2990
  ),
2992
2991
  style: containerStyle,
@@ -3015,22 +3014,12 @@ function STabs({
3015
3014
  color: selected ? TAB_MAIN_COLORS.selected.text : TAB_MAIN_COLORS.default.text,
3016
3015
  border: `1px solid ${border}`,
3017
3016
  // 폴더형: 미선택 탭은 baseline 위에, 선택 탭은 baseline(파란선)을 1px 덮어 연결된 것처럼 보이게
3018
- ...vertical ? {
3019
- borderTopLeftRadius: 6,
3020
- borderBottomLeftRadius: 6,
3021
- borderTopRightRadius: 0,
3022
- borderBottomRightRadius: 0,
3023
- justifyContent: "flex-start",
3024
- borderRight: selected ? `1px solid ${selectedBg}` : "none",
3025
- marginRight: selected ? -1 : 0
3026
- } : {
3027
- borderTopLeftRadius: 6,
3028
- borderTopRightRadius: 6,
3029
- borderBottomLeftRadius: 0,
3030
- borderBottomRightRadius: 0,
3031
- borderBottom: selected ? `1px solid ${selectedBg}` : "none",
3032
- marginBottom: selected ? -1 : 0
3033
- }
3017
+ borderTopLeftRadius: 6,
3018
+ borderTopRightRadius: 6,
3019
+ borderBottomLeftRadius: 0,
3020
+ borderBottomRightRadius: 0,
3021
+ borderBottom: selected ? `1px solid ${selectedBg}` : "none",
3022
+ marginBottom: selected ? -1 : 0
3034
3023
  };
3035
3024
  }
3036
3025
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -3050,8 +3039,8 @@ function STabs({
3050
3039
  select();
3051
3040
  return;
3052
3041
  }
3053
- const nextKey = vertical ? "ArrowDown" : "ArrowRight";
3054
- const prevKey = vertical ? "ArrowUp" : "ArrowLeft";
3042
+ const nextKey = isVertical ? "ArrowDown" : "ArrowRight";
3043
+ const prevKey = isVertical ? "ArrowUp" : "ArrowLeft";
3055
3044
  if (e.key === nextKey) {
3056
3045
  e.preventDefault();
3057
3046
  moveTo((i + 1) % tabs.length);