sellmate-design-system-react 9.0.0-beta.54 → 9.0.0-beta.56

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
@@ -605,6 +605,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
605
605
 
606
606
  **`SScrollArea` 로 페이지 본문 전체를 감싸지 않는다.** 페이지 스크롤은 `SPage` 의 `<main>` 몫이다 — 감싸면 스크롤바가 본문 패딩 안쪽으로 들어와 페이지 가장자리에서 떨어져 그려진다 (§2-2).
607
607
 
608
+ **`SScrollArea` 에는 상한을 정해 준다 — `maxHeight` 를 주거나, 부모가 높이를 정한 자리에 놓는다.** 둘 다 없으면 영역이 내용만큼 자라 스크롤이 생기지 않는다. 남은 높이를 쓰는 자리라면 `maxHeight` 가 아니라 `className="min-h-0 flex-1"` 로 사슬을 잇는다 — 픽셀로 박으면 창 높이가 바뀔 때 따라가지 못한다.
609
+
608
610
  #### E. 다른 곳으로 이동시킨다
609
611
 
610
612
  | 하려는 일 | 컴포넌트 | 갈림 |
@@ -621,7 +623,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
621
623
  | --- | --- | --- |
622
624
  | 실행 여부만 확정받는다 | `SModal.confirm()` | §3-3-1 |
623
625
  | 모달 안에서 작성·선택하게 한다 | `SActionModal` + `SModal.create()` | §3-3-1 |
624
- | 띄우는 것 자체가 하나의 화면이다 | `SPopup` | §3-3-1 |
626
+ | 띄우는 것 자체가 하나의 화면이다 | `SPopup` | §3-3-1 · 골격은 §4-7 |
625
627
  | 화면 옆에서 밀려 나오는 작업 패널을 연다 | `SDrawer` | §3-3-5 |
626
628
  | 확인 다이얼로그를 화면에 직접 배치한다 | `SConfirmModal` | §3-3-4 |
627
629
  | 클릭하면 상호작용 가능한 작은 콘텐츠를 띄운다 | `SPopover` | §3-3 |
@@ -773,30 +775,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
773
775
  - **본문 패딩은 `SPopup` 이 토큰으로 넣는다. 직접 주지 않는다** (`p-sd-*` 로 덮어쓰면 토큰이 바뀌어도 안 따라간다). 표를 가장자리까지 채우는 등 콘텐츠가 여백을 직접 다뤄야 할 때만 `padding="none"` 으로 끈다.
774
776
  - 그 밖에는 팝업 안도 일반 페이지와 같은 규칙(§4)을 따른다: 블록 간격 `gap-sd-12`, 표는 `SKeyValueTable` / `STable`.
775
777
 
776
- ```tsx
777
- // 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
778
- function openDetailPopup(orderId: string) {
779
- window.open(
780
- `${window.location.origin}/popup/transfer-orders/${orderId}`,
781
- `transfer-order-${orderId}`,
782
- 'width=1200, height=800, toolbar=no, menubar=no, location=no, resizable=no',
783
- );
784
- }
785
-
786
- // 2) 그 라우트의 루트에 SPopup 을 둔다 (조회만 → 푸터 없음)
787
- export default function TransferOrderPopupPage() {
788
- return (
789
- <SPopup popupTitle="이동 오더 상세">
790
- {/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
791
- <div className="flex flex-col gap-sd-12">
792
- <SSectionHeaderCard>…</SSectionHeaderCard>
793
- <STabs value={tab} tabs={TABS} onValueChange={setTab} />
794
- <STable columns={columns} rows={rows} rowKey="id" />
795
- </div>
796
- </SPopup>
797
- );
798
- }
799
- ```
778
+ **창을 여는 코드와 그 라우트의 골격은 §4-7 에 있다.** 창 높이를 `SPopup` 까지 잇는 한 줄이 빠지면 헤더·푸터가 창 밖으로 밀려나므로, 팝업 라우트는 반드시 §4-7 골격에서 시작한다.
800
779
 
801
780
  #### 3-3-4. 모달 만드는 법
802
781
 
@@ -833,6 +812,32 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
833
812
 
834
813
  **`button` 은 `SButton` 의 prop 을 그대로 받는다** — `icon`·`rightIcon`·`outline`·`disabled` 등을 함께 넘길 수 있다. 단 **`size` 는 타입에 없다**: 하단 액션 영역은 언제나 `md` 이고(§3-5-2) 예외가 없어 푸터가 고정한다. **`color` 는 주지 않는 것이 기본**이다 — 주지 않으면 주 액션 규칙대로 `primary` 가 되고, 그 버튼이 파괴적 액션일 때만 §3-5-3 에 따라 `danger` 를 준다. 카드(§3-7-8)의 `button` 도 같다.
835
814
 
815
+ **확인 모달은 확인 버튼 하나로 세우는 것이 기본이다 — `subButtonLabel` 을 주지 않는다.** 비워 두면 서브 버튼이 아예 서지 않는다. 답하지 않고 나가는 길은 **X 버튼이 이미 맡고**(늘 서 있다), `persistent` 를 켜지 않았으면 백드롭·ESC 로도 닫힌다(§3-3-5).
816
+
817
+ **서브 버튼은 `onCancel` 이 닫는 것 말고 따로 할 일이 있을 때만 둔다.** 눌러도 창만 닫힌다면 X 와 똑같은 일을 하는 버튼이 하나 더 서는 것이라, 어느 것을 눌러야 하는지가 오히려 흐려진다. **되묻는 모달이라고 예외가 아니다** — "삭제할까요?" 에 답하지 않고 나가는 것은 X 로 충분하다.
818
+
819
+ 그래서 서브 버튼이 설 자리는 **확인과 대등한 두 번째 선택지**뿐이고, 그때 글자는 `취소`·`닫기` 가 아니라 그 버튼이 하는 일의 이름이 된다.
820
+
821
+ ```tsx
822
+ ✅ SModal.confirm({ type: 'negative', modalTitle: '삭제할까요?', mainButtonLabel: '삭제' })
823
+ .onOk(() => remove())
824
+ ✅ SModal.confirm({ type: 'negative', modalTitle: '재고가 모자란 주문이 있습니다',
825
+ mainButtonLabel: '자동 취소', subButtonLabel: '수동 처리' })
826
+ .onOk(() => cancelShortOrders()).onCancel(() => openManualFlow())
827
+ // 서브가 창을 닫는 것이 아니라 다른 흐름을 시작한다
828
+
829
+ ❌ SModal.confirm({ …, mainButtonLabel: '삭제', subButtonLabel: '취소' }).onCancel(() => {})
830
+ // 눌러도 닫히기만 한다 — X 가 이미 하는 일
831
+ ❌ SModal.confirm({ …, mainButtonLabel: '확인', subButtonLabel: '닫기' })
832
+ // 닫으려고 붙인 버튼 — X 와 겹친다
833
+ ❌ SModal.confirm({ type: 'positive', modalTitle: '저장되었습니다', mainButtonLabel: '확인', subButtonLabel: '취소' })
834
+ // 이미 끝난 일 옆의 취소 — 무엇이 취소되는지 알 수 없다
835
+ ```
836
+
837
+ 습관적으로 `subButtonLabel: '취소'` 를 옵션에 넣지 않는다. **`onCancel` 에 적을 것이 없으면 서브 버튼도 없다.**
838
+
839
+ **확인 모달의 확인 버튼은 `mainButtonDisabled` 로 잠근다.** 기본값이 `false` 이므로 **아직 확정할 수 없을 때만** 준다 — 동의를 안 받았거나, `contentSlot` 의 입력이 덜 찼거나, 확인 뒤에 나갈 요청이 이미 나가는 중일 때. **왜 못 누르는지를 화면에 함께 둔다**(`bottomMessage` 나 `contentSlot`): 흐려진 버튼만 남으면 사용자가 고장으로 읽는다. **취소는 따라 잠그지 않는다** — 확정하지 못하는 상태일수록 나가는 길은 열려 있어야 해서 서브 버튼을 막는 prop 자체가 없다. 조건이 채워지면 `SModal.confirm` 이 돌려준 핸들로 `update({ mainButtonDisabled: false })` 해서 푼다.
840
+
836
841
  **모달 안에서도 앱의 훅을 그냥 쓴다 — 단, 앱 루트에 `SModalOutlet` 이 있어야 한다 (§4-1).** outlet 이 있으면 명령형 모달이 앱 렌더 트리의 자식으로 그려지므로 `useQuery`·`useNavigate`·`useTheme` 같은 Context 기반 훅이 페이지에서와 똑같이 동작한다. **모달 컴포넌트를 Provider 로 다시 감싸지 않는다.** outlet 없이 띄우면 모달이 별도 React 루트로 떠서 Provider 가 하나도 닿지 않고, `No QueryClient set` 처럼 모달을 여는 순간에만 터진다.
837
842
 
838
843
  #### 3-3-5. 닫기 경로 — `persistent` 기본값은 컴포넌트마다 다르다
@@ -843,7 +848,7 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
843
848
  | --- | --- | --- | --- |
844
849
  | `SActionModal` · `SLoadingModal` | `true` | X 버튼, 모달 안의 버튼 | 막힘 (흔들림) |
845
850
  | `SDrawer` | `true` | X 버튼, footer 버튼 | 막힘 (흔들림) |
846
- | `SConfirmModal` | `false` | X 버튼, 확인/취소 버튼 | **닫힌다** |
851
+ | `SConfirmModal` | `false` | X 버튼, 확인 버튼 | **닫힌다** |
847
852
  | `SPopover` · `STooltip` · `SSelect` 등 floating | — | 바깥 클릭·ESC | **막지 않는다** — 이 규칙의 대상이 아니다 |
848
853
 
849
854
  따라서 다음을 지킨다.
@@ -852,6 +857,7 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
852
857
  - **기본값과 같은 `persistent` 를 직접 주지 않는다.** 중복이다.
853
858
  - **`SActionModal`·`SDrawer` 에 `persistent={false}` 는 잃을 입력이 없을 때만.** 단순 알림처럼 임의로 닫혀도 아무것도 사라지지 않는 경우로 한정한다.
854
859
  - **`SConfirmModal` 에 `persistent` 를 켜는 것은 반드시 답을 받아야 할 때만.** 되돌릴 수 없는 파괴적 작업의 확인처럼, 임의로 닫히면 안 되는 경우로 한정한다.
860
+ - **`SConfirmModal` 에 `persistent` 와 `mainButtonDisabled` 를 함께 켜면 나가는 길이 X 하나만 남는다.** 갇히지는 않지만(X 는 늘 서 있다) 확인이 왜 잠겼는지가 화면에 없으면 막다른 길로 읽힌다 — 잠근 이유를 `bottomMessage` 나 `contentSlot` 에 함께 둔다(§3-3-4).
855
861
 
856
862
  작성 중인 내용이 있을 때 닫기를 시도하면 이탈 안내를 띄우는 것은 **소비 앱 몫**이다. 디자인 시스템은 dirty 상태를 알지 못하므로 백드롭·ESC 를 일괄 차단할 뿐이다. 안내가 필요하면 앱이 자체 dirty 판정 후 `SModal.confirm` 으로 띄운다.
857
863
 
@@ -1364,7 +1370,9 @@ tableRef.current.scrollToRow(row); // 복원
1364
1370
 
1365
1371
  **"이 계정으로 갈 수 있는 다른 서비스가 무엇인가"를 한 자리에 모은 목록**이다. 한 줄이 서비스 하나이고, 위에 브랜드 로고(+ 상태 태그) 아래에 서비스 이름이 선다.
1366
1372
 
1367
- **직접 띄우지 않는다.** 런처 버튼에 붙여 여는 것은 `SGnb` 의 `launcher` 가 맡는다 — 목록만 넘기면 버튼 아래로 · 버튼 왼쪽 끝에 맞춰 뜨고, 서비스를 고르거나 바깥을 누르면 닫힌다.
1373
+ **직접 띄우지 않는다.** 런처 버튼에 붙여 여는 것은 `SGnb` 의 `launcher` 가 맡는다 — 목록만 넘기면 버튼 아래로 · 버튼 왼쪽 끝에 맞춰 뜨고, 서비스를 고르거나 바깥을 누르면 닫힌다. 고른 서비스는 **새 탭에서 열린다** — 이동도 앱이 만들지 않는다.
1374
+
1375
+ **앱이 넘기는 것은 서비스 키와 갈 주소 둘뿐이다.** 로고·서비스 표기·이름·상태 태그는 디자인 시스템의 카탈로그가 서비스마다 정해 두므로 앱이 만들지 않는다.
1368
1376
 
1369
1377
  ```tsx
1370
1378
  <SGnb
@@ -1372,35 +1380,36 @@ tableRef.current.scrollToRow(row); // 복원
1372
1380
  logo={<Logo />}
1373
1381
  launcher={{
1374
1382
  items: [
1375
- { value: 'sellmate', name: '셀메이트' },
1376
- { value: 'wms', service: 'WMS', name: '셀메이트 WMS' },
1377
- { value: 'account', service: 'Account', name: '셀메이트 어카운트',
1378
- tag: 'NEW', tagColor: 'blue', tagIcon: 'star' },
1379
- { value: 'crm', service: 'CRM', name: '셀메이트 CRM', tag: '출시예정', disabled: true },
1383
+ { service: 'sellmate' }, // 없는 줄 — 주소를 주지 않는다
1384
+ { service: 'wms', href: env.WMS_URL }, // 주소는 앱의 환경(QA·실서버)에서 온다
1385
+ { service: 'account', href: env.ACCOUNT_URL },
1386
+ { service: 'crm', href: env.CRM_URL },
1380
1387
  ],
1381
- onSelect: value => go(SERVICE_URL[value]),
1382
1388
  }}
1383
1389
  />
1384
1390
  ```
1385
1391
 
1386
1392
  - **`launcher` 를 주지 않으면 런처 버튼 자체가 렌더되지 않는다.** 옮겨 갈 서비스가 없는 앱은 이 슬롯을 비워 둔다 — 눌러도 아무것도 없는 버튼을 상단바에 남기지 않는다.
1387
- - **어디로 갈지는 `launcher.onSelect` 하나로 받는다.** 줄마다 콜백을 두지 않는다 목록에서 일어나는 일은 "어느 서비스로 옮겨 가는가" 하나뿐이라, 고른 줄의 `value` 하나면 곳이 정해진다. 고르면 목록은 스스로 닫히고, `disabled` 줄은 눌리지 않아 오지 않는다. (런처 **버튼**을 누른 것은 `launcher.onClick` 으로 따로 온다 여는 말고 일이 있을 때만 준다.)
1388
- - **셀메이트 워드마크는 리스트박스가 늘 그린다.** 앱이 넘기는 것은 그 오른쪽에 잇는 **서비스 표기(`service`)** 뿐이다 — `WMS` · `Account` · `CRM` 같은 것. 워드마크를 앱이 넘기게 하면 같은 로고가 화면마다 다른 크기·색으로 선다. 셀메이트 본체처럼 표기가 없는 줄은 `service` 를 주지 않으면 워드마크만 선다.
1389
- - **표기를 글자로 넘기면 크기·굵기·색을 주지 않는다.** 워드마크 옆에 서는 글자는 로고의 일부처럼 읽혀야 해서 리스트박스가 한 모습으로 정한다(14px bold · `fg.accentLight`). 서비스마다 색을 달리 주면 로고가 서비스마다 다른 물건처럼 보인다. 서비스가 자기 그래픽 마크를 가지면 svg·img 로 넘기고, 그때도 정해진 높이에 비율대로 들어가므로 **크기를 직접 주지 않는다.**
1393
+ - **서비스는 키(`service`)로 고른다 보이는 것은 아무것도 넘기지 않는다.** 있는 키는 `'sellmate' | 'wms' | 'account' | 'crm'`(`SLauncherService`)이고, 로고 오른쪽 표기(`WMS`·`Account`·`CRM`)·아래 한글 이름·상태 태그(`NEW`·`출시예정`)·출시 여부를 카탈로그(`LAUNCHER_SERVICE_CATALOG`)가 키로 정한다. 앱이 넘기게 하면 같은 서비스가 앱마다 다른 이름·색·문구로 선다. **서비스가 늘거나 이름·상태가 바뀌면 디자인 시스템 릴리스로 따라온다** 앱은 고칠 것이 없다.
1390
1394
 
1391
1395
  ```tsx
1392
- ✅ { value: 'sellmate', name: '셀메이트' } // 표기 없음 — 워드마크만
1393
- { value: 'wms', service: 'WMS', name: '셀메이트 WMS' }
1394
- { value: 'x', service: <XMark />, name: '자기 마크를 가진 서비스' }
1396
+ ✅ { service: 'wms', href: env.WMS_URL }
1397
+ { service: 'WMS', name: '셀메이트 WMS', href: … } // 표기·이름을 앱이 정하지 않는다
1398
+ { service: 'wms', tag: 'NEW', href: } // 태그도 앱이 정하지 않는다
1399
+ ❌ { service: <><SLogo size={12} />WMS</>, … } // 로고를 다시 넘기지 않는다
1400
+ ```
1401
+ - **주소는 앱이 준다 — 디자인 시스템은 주소를 들지 않는다.** 앱마다 QA·실서버 주소가 달라 한곳에서 관리할 수 없기 때문이다. `href` 를 주면 그 줄은 링크가 되어 **새 탭에서 열린다** — 가운데 클릭·⌘클릭·주소 복사가 그대로 듣는다.
1402
+ - **이동을 앱이 다시 하지 않는다.** `launcher.onSelect` 는 **고른 것을 알려 줄 뿐 화면을 옮기지 않는다** — 여기서 `window.open` 이나 라우터를 부르면 탭이 두 번 열린다. 기록을 남기는 것처럼 이동 말고 할 일이 있을 때만 준다. 고르면 목록은 스스로 닫히고, 갈 수 없는 줄은 눌리지 않아 오지 않는다. (런처 **버튼**을 누른 것은 `launcher.onClick` 으로 따로 온다.)
1403
+ - **`href` 를 주는 것이 곧 "갈 수 있다"는 뜻이다 — 줄을 막는 prop 은 없다.** 주소를 주지 않으면 그 줄은 목록에 남되 눌리지 않고 물러난다. 지금 보고 있는 서비스든 이 앱이 계약하지 않은 서비스든 갈 곳이 없기는 같아서 한 가지로 다룬다.
1395
1404
 
1396
- ❌ { value: 'wms', service: <><SLogo size={12} />WMS</>, … } // 워드마크를 다시 넘기지 않는다
1397
- { value: 'wms', service: <span className="typo-body-sm-bold text-fg-success">WMS</span>, }
1398
- // 글자의 색·크기를 앱이 정하지 않는다
1405
+ ```tsx
1406
+ { service: 'sellmate' } // 없다 주소를 주지 않는다
1407
+ ❌ { service: 'sellmate', disabled: true } // 줄을 막는 prop 없다
1399
1408
  ```
1400
- - **아직 수 없는 서비스는 목록에서 빼지 않고 `disabled` 로 둔다.** 그 서비스가 있다는 사실과 아직쓴다는 사실을 함께 알리는 것이 런처의 일이다. **왜 쓰는지를 `tag` 함께 적는다**(`출시예정`·`미계약` 등) 이유 없이 흐려진 줄은 고장으로 읽힌다.
1401
- - **태그는 알릴 것이 있는 줄에만 붙인다.** 모든 줄에 붙으면 무엇이 새것인지가 읽히지 않는다.
1402
- - **줄에 얹으면 "여기서 나간다"는 것이 드러난다** 배경이 옅은 파랑으로 물들고 이름이 단계 진해지며, 오른쪽 끝에 페이지 이동 아이콘이 떠오른다. **앱이 켜고 끄는 prop 이 없다** — 다른 서비스로 넘어가는 자리라는 신호는 화면마다 달라지면 된다. `disabled` 줄은 얹어도 반응하지 않는다.
1403
- - **지금 보고 있는 서비스를 표시하는 prop 없다.** 런처는 "여기 말고 어디로 있는가"를 보여주는 자리라 현재 위치는 GNB 이미 말하고 있다.
1409
+ - **갈 수 없는 서비스도 목록에서 빼지 않는다.** 그 서비스가 있다는 사실과 지금은간다는 사실을 함께 알리는 것이 런처의 일이다. 지금 보고 있는 서비스를 빼면 사용자가 지금 어디에 있는지가 런처에서 사라진다.
1410
+ - **출시 전인 서비스는 앱이 일이 없다.** 카탈로그가 `출시예정` 태그와 함께 막아 두므로 주소를 줘도 열리지 않고, 열리는 날 디자인 시스템 릴리스 하나로 모든 앱에서 함께 열린다. **앱이 앞질러 열 수단은 없다.**
1411
+ - **목록 순서를 앱이 정하지 않는다.** 넘긴 순서가 아니라 카탈로그 순서(`LAUNCHER_SERVICES`)로 선다 같은 런처가 앱마다 다른 순서로 뜨면 사용자가 매번 다시 찾는다. 정렬해서 넘기려 애쓰지 않는다.
1412
+ - **줄에 얹으면 "여기서 나간다"는 것이 드러난다** — 배경이 옅은 파랑으로 물들고 이름이 한 단계 진해지며, 오른쪽 끝에 페이지 이동 아이콘이 떠오른다. **앱이 켜고 끄는 prop 없다** 다른 서비스로 넘어가는 자리라는 신호는 화면마다 달라지면 된다. 없는 줄은 얹어도 반응하지 않는다.
1404
1413
  - **폭을 늘리지 않는다.** 로고 길이에 따라 패널이 출렁이지 않도록 고정 폭이다.
1405
1414
  - **뜨는 방향은 prop 이 아니다.** 런처 버튼이 상단바 왼쪽 끝에 있으므로 **아래로 · 버튼 왼쪽 끝에 맞춰** 펼친다. 방향을 바꾸는 prop 은 없으니 찾지 않는다.
1406
1415
  - **`header="fix"` 에서 GNB 를 접으면 열려 있던 목록은 닫힌다.** 접힘 레일에는 폴드 버튼만 남아 런처 버튼이 화면에서 사라지기 때문이다. **그 닫힘을 알아야 하면 `launcher.onOpenChange` 를 준다** — 열림을 따라 그리는 화면이 앱에 있을 때만 필요하다.
@@ -1445,6 +1454,7 @@ tableRef.current.scrollToRow(row); // 복원
1445
1454
  - 크기 단위는 `unit` 이 정한다. 기본 `'%'` 는 창이 바뀌어도 비율을 유지하고, `'px'` 는 폭을 유지한다. **사이드바처럼 폭이 고정돼야 하는 자리는 `'px'`**, 화면을 비율로 나누는 자리는 기본값 그대로 둔다.
1446
1455
  - 본문이 읽을 수 없을 만큼 좁아지지 않도록 `limits={[최소, 최대]}` 를 준다. 생략하면 `'%'` 는 `[10, 90]`, `'px'` 는 `[50, Infinity]`.
1447
1456
  - 각 패널은 넘치는 만큼 **스스로 스크롤한다.** 패널 안에 `SScrollArea` 를 겹쳐 넣지 않는다.
1457
+ - **높이는 놓는 자리가 준다.** `SSplitter` 는 부모를 채우기만 하므로, 부모 높이가 `auto` 면 패널이 내용 높이로 자라 스크롤이 생기지 않는다 (`vertical` 은 위아래 비율 자체가 무의미해진다). 페이지 본문의 남은 높이를 쓰려면 `contentHeight="fill"` 에 스택을 `min-h-0 flex-1` 로 이어 준다 — `SCalendarBoard`·`STable` 과 같은 사슬이다 (§2-2).
1448
1458
  - 모델은 항상 **첫 패널**(`SSplitter.Before`) 크기다. 사이드가 기준인 화면이면 사이드를 `Before` 에 둔다.
1449
1459
  - 앱 셸의 GNB 폭은 `SGnb` 가 소유한다. `SLayout`/`SGnb` 를 `SSplitter` 로 감싸지 않는다 — **GNB 폭을 끌 수 있게 하려면 `SGnb` 에 `resizable` 을 준다**(§4-1).
1450
1460
 
@@ -1899,6 +1909,17 @@ export default function AppShell({
1899
1909
  }
1900
1910
  ```
1901
1911
 
1912
+ **셸을 마운트하는 자리가 뷰포트 높이를 준다 — 앱 루트에 `h-screen` 을 둔다.** `SLayout` 은 부모 높이를 채우도록만 되어 있어서, 마운트 지점의 높이가 `auto` 면 100% 가 풀려 셸이 내용 높이로 줄어든다. 그러면 GNB 가 화면 바닥까지 내려오지 않고, `SPage` 안에서 일어나야 할 스크롤이 문서(브라우저 창) 스크롤이 되어 `contentHeight="fill"` 도 최소 너비의 가로 스크롤 규칙도 함께 무너진다. `html`·`body` 에 전역 CSS 를 걸 필요는 없다 — 루트 한 겹이면 된다.
1913
+
1914
+ ```tsx
1915
+ // 앱 진입점 (main.tsx) — 셸이 창 높이를 받는 자리는 여기 하나다
1916
+ createRoot(document.getElementById('root')!).render(
1917
+ <div className="h-screen">
1918
+ <AppShell header={{ variant: 'bar', title: '주문 목록' }}>…</AppShell>
1919
+ </div>,
1920
+ );
1921
+ ```
1922
+
1902
1923
  **페이지는 `AppShell` 을 직접 호출하며 자기 `header` 를 넘긴다** — 셸은 앱에 하나뿐이므로, 페이지 제목이 페이지마다 다르다는 사실은 이렇게 프레임 컴포넌트를 통해 흘려보낸다(§4-2·§4-3·§4-4 참고).
1903
1924
 
1904
1925
  **자식 순서는 `SGnb` → `SPageHeader` → `SPage` 다.** `SLayout` 은 `SPageHeader` 자식을 보면 **그 자식부터 뒤를** 하나의 페이지 열로 묶어 헤더를 페이지 위에 고정한다 — 스크롤도 페이지 패딩도 그 아래 `SPage` 안에서만 일어난다. 그래서 순서가 규칙이다: 헤더를 `SGnb` 앞에 두면 GNB 까지 페이지 열로 딸려 들어가고, `SPage` 의 `children` 안에 넣으면 본문 패딩 안으로 들어가 스크롤과 함께 밀려 올라간다.
@@ -1986,7 +2007,7 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1986
2007
  - 슬롯이 남는 폭을 통째로 받으므로 **정렬은 안에서 직접 잡는다** (좌측 정렬 + 우측은 `ml-auto`).
1987
2008
  - `header="full"` 에서 로고 자리는 140px 로 고정된다 — 로고 내용이 바뀌어도 `topContent` 시작점이 흔들리지 않게 하기 위함이다. 로고가 그보다 넓으면 잘리므로 이 폭에 맞춰 준비한다.
1988
2009
  - `launcher` 를 주지 않으면 런처가 렌더되지 않고, 그 자리(버튼 + 간격)를 로고 슬롯이 이어받아 172px 가 된다. `topContent` 시작점은 런처 유무와 관계없이 같은 자리다.
1989
- - **런처 버튼은 서비스 목록의 트리거다.** `launcher.items` 에 옮겨 갈 서비스를 넘기면 버튼 아래로 목록이 뜬다팝오버를 손으로 조립하지 않는다 (§3-5-9).
2010
+ - **런처 버튼은 서비스 목록의 트리거다.** `launcher.items` 에 옮겨 갈 서비스를 **키와 주소로** 넘기면 버튼 아래로 목록이 뜨고, 고른 서비스가 새 탭에서 열린다 팝오버도 이동도 손으로 만들지 않는다 (§3-5-9).
1990
2011
 
1991
2012
  ```tsx
1992
2013
  <SLayout type="box" header="full">
@@ -2415,7 +2436,7 @@ export default function ProductDetailPage() {
2415
2436
 
2416
2437
  ### 4-6. 로그인 화면 — SLoginCard
2417
2438
 
2418
- 앱 셸이 아직 없는 유일한 화면이다. `SLayout`·`SGnb`·`SPage` 가 없고, 회색 바탕 위에 카드 하나만 선다.
2439
+ 로그인 전이라 앱 셸이 아직 없다. `SLayout`·`SGnb`·`SPage` 가 없고, 회색 바탕 위에 카드 하나만 선다. (앱 셸 없이 서는 화면은 이것과 팝업 라우트(§4-7) 둘뿐이다.)
2419
2440
 
2420
2441
  ```tsx
2421
2442
  <div className="flex h-screen items-stretch justify-center bg-(--sys-color-bg-neutralLight) p-sd-24">
@@ -2457,6 +2478,66 @@ export default function ProductDetailPage() {
2457
2478
 
2458
2479
  **로그인 카드 옆에 다른 블록을 두지 않는다.** 이 화면에서 할 일은 로그인 하나이고, 옆에 공지·배너가 붙는 순간 그 뜻이 깨진다. 안내가 필요하면 `description` 이나 `footerNote` 로 넣는다.
2459
2480
 
2481
+ ### 4-7. 팝업 창 라우트 — SPopup
2482
+
2483
+ 앱 셸 없이 서는 또 하나의 화면이다. 별도 브라우저 창으로 열리는 전용 라우트이므로 `SLayout`·`SGnb`·`SPage` 가 없고, 창을 통째로 `SPopup` 하나가 채운다. 무엇을 팝업으로 열지는 §3-3-1·§3-3-3 에서 고르고, 여기서는 그 라우트의 골격만 다룬다.
2484
+
2485
+ ```tsx
2486
+ // 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
2487
+ function openDetailPopup(orderId: string) {
2488
+ window.open(
2489
+ `${window.location.origin}/popup/transfer-orders/${orderId}`,
2490
+ `transfer-order-${orderId}`,
2491
+ 'width=1200, height=800, toolbar=no, menubar=no, location=no, resizable=no',
2492
+ );
2493
+ }
2494
+
2495
+ // 2) 그 라우트의 루트는 SPopup 하나다 (조회만 → 푸터 없음)
2496
+ export default function TransferOrderPopupPage() {
2497
+ return (
2498
+ // h-screen 으로 창 높이를 잡는다 — 이게 없으면 본문이 창 밖으로 자란다
2499
+ <SPopup className="h-screen" popupTitle="이동 오더 상세">
2500
+ {/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
2501
+ <div className="flex flex-col gap-sd-12">
2502
+ <SSectionHeaderCard>…</SSectionHeaderCard>
2503
+ <STabs value={tab} tabs={TABS} onValueChange={setTab} />
2504
+ <STable columns={columns} rows={rows} rowKey="id" />
2505
+ </div>
2506
+ </SPopup>
2507
+ );
2508
+ }
2509
+ ```
2510
+
2511
+ **높이는 라우트가 준다 — `SPopup` 에 `h-screen` 을 준다.** `SPopup` 자체는 부모 높이를 채우도록만 되어 있어서, 부모(라우트 루트·`body`)가 높이를 정해 주지 않으면 100% 가 풀려 본문이 창 밖으로 자란다. 그러면 헤더·푸터가 위아래로 밀려 창 안에 보이지 않는다. `h-screen` 이면 `html`·`body`·마운트 루트에 전역 CSS 를 걸지 않고도 창 높이가 바로 들어온다. (`body` 여백은 0 이어야 한다 — Tailwind preflight 가 이미 0 으로 만든다.)
2512
+
2513
+ **창 높이를 고정으로 가정하지 않는다.** `window.open` 의 `resizable=no` 는 브라우저가 무시하는 경우가 많아 사용자가 창을 늘리고 줄일 수 있고, 화면 해상도에 따라 처음 열리는 높이도 요청값과 달라진다. `h-screen` 으로 이어 두면 본문의 가용 높이가 창과 함께 변하고, 넘칠 때만 **본문만** 스크롤한다 — 헤더와 푸터는 자리에 남는다. 높이를 `px` 로 박거나 리사이즈를 JS 로 따라가지 않는다.
2514
+
2515
+ **표를 담으면 본문이 아니라 표가 스크롤한다.** 본문이 통째로 스크롤되면 표 헤더와 페이지네이션 바가 위아래로 밀려 사라진다. `SPopup` 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고 표에 `min-h-0 flex-1` 을 준다. **툴바+표를 묶은 `div` 도 사슬의 한 칸이라 거기에도 `min-h-0 flex-1` 이 필요하다** (§4-2 목록 페이지와 같은 사슬이다 — 한 칸만 끊겨도 표가 높이를 못 잡는데 실패가 조용하다).
2516
+
2517
+ ```tsx
2518
+ <SPopup
2519
+ className="h-screen"
2520
+ popupTitle="엑셀 파일 관리"
2521
+ showFooter // 확정할 작업이 있을 때만 (§3-3-3)
2522
+ submitButton={{ label: '저장' }}
2523
+ onSubmit={save}
2524
+ >
2525
+ <div className="flex h-full min-h-0 flex-col gap-sd-12">
2526
+ <SKeyValueTable fields={filterFields} values={filters} search
2527
+ onChange={({ values }) => setFilters(values)} onSearch={fetchList} />
2528
+
2529
+ {/* 툴바+표 = 한 블록. 이 div 가 사슬의 한 칸이다 (§2-0) */}
2530
+ <div className="flex min-h-0 flex-1 flex-col">
2531
+ <STableBar className="border-b-0" total={total} />
2532
+ {/* 표만 자기 안에서 스크롤한다 — 필터·툴바·푸터는 늘 보인다 */}
2533
+ <STable className="min-h-0 flex-1" columns={columns} rows={rows} rowKey="id"
2534
+ pagination={{ currentPage, lastPage }} />
2535
+ </div>
2536
+ </div>
2537
+ </SPopup>
2538
+ ```
2539
+
2540
+ 조회만 하고 표가 없는 팝업은 이 사슬이 필요 없다 — 본문이 통째로 스크롤되어도 헤더는 고정이고 밀려날 푸터가 없다.
2460
2541
 
2461
2542
  ---
2462
2543
 
@@ -2476,6 +2557,8 @@ export default function ProductDetailPage() {
2476
2557
  - [ ] `SSectionHeaderCard` 의 여백을 `p-sd-*` 가 아니라 `padding` prop 으로 줬는가
2477
2558
  - [ ] 페이지에 `contentHeight="fill"` 을 넘겼는가 (§2-2 표준 — 페이지 스크롤을 쓰는 화면에서만 `auto` + `scrollEndSpacing`)
2478
2559
  - [ ] `fill` 을 쓴 블록에서 **자식까지 `min-h-0 flex-1` 이 끊기지 않았는가** (한 군데만 끊겨도 자식이 높이를 못 잡는데 실패가 조용하다)
2560
+ - [ ] 셸을 마운트하는 앱 루트가 뷰포트 높이인가 (§4-1 — `h-screen` 이 없으면 GNB 가 바닥까지 안 오고 페이지 스크롤이 문서 스크롤이 된다)
2561
+ - [ ] 팝업 라우트라면 `SPopup` 에 `h-screen` 을 줬는가 (§4-7 — 없으면 본문이 창 밖으로 자라 헤더·푸터가 밀려난다), 표를 담았다면 표만 스크롤하는가
2479
2562
  - [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가
2480
2563
  - [ ] 한 페이지에 섹션 카드가 둘 이상이면 `marker` 를 켰는가, 하나뿐이면 껐는가 (§4-5)
2481
2564
  - [ ] 같은 컴포넌트를 나열할 때 §2-2 그룹 간격을 썼는가 (체크박스 가로 `gap-sd-24` 등)
@@ -25,7 +25,8 @@
25
25
  | `contentSlot?` | `ReactNode` | — | 콘텐츠 박스 바로 아래에 통으로 들어가는 커스텀 콘텐츠 (전체 폭) |
26
26
  | `mainButtonLabel?` | `string` | `'확인'` | 메인(확인) 버튼에 찍히는 글자 |
27
27
  | `mainButtonOutline?` | `boolean` | — | 메인 버튼을 아웃라인(테두리)으로 세울지. 지정하지 않으면 `type` 이 정한다 — positive·negative 는 채운 버튼, default 는 아웃라인. **색은 여기서 고르지 않는다.** `type` 이 정한다(positive=primary / negative=danger / default=neutral) — 아이콘·제목이 말하는 것과 버튼 색이 어긋나면 읽는 사람이 헷갈리므로 그 조합은 타입에서 막는다. 글자는 `mainButtonLabel` 이다. `type="default"` 에 `false` 를 주면 테두리 없는 흰 버튼이 된다(neutral 은 solid·outline 이 같은 흰 면이고 테두리 유무만 다르다). 확인 버튼이 눌러야 할 것으로 안 읽히므로 권하지 않는다. |
28
- | `subButtonLabel?` | `string` | | 서브(취소) 버튼 |
28
+ | `mainButtonDisabled?` | `boolean` | `false` | 메인 버튼을 눌리지 않게 한다. **기본값 `false`.** 모달 안에서 **아직 확정할 수 없을 때** 쓴다 — 동의 체크를 아직 안 했거나, `contentSlot` 의 입력이 덜 찼거나, 확인 뒤에 나갈 요청이 이미 나가는 중일 때. **왜 못 누르는지는 화면이 말해야 한다.** 흐려진 버튼만 남으면 사용자는 고장으로 읽는다 — 못 채운 것을 `bottomMessage` 나 `contentSlot` 안에서 함께 보인다. 서브(취소) 버튼은 따라 잠기지 않는다. 확정하지 못하는 상태일수록 **나가는 길은 열려 있어야** 하기 때문이다. 그래서 서브 버튼을 막는 prop 은 두지 않는다. |
29
+ | `subButtonLabel?` | `string` | — | 서브 버튼에 찍히는 글자. **주지 않는 것이 기본이다** — 비어 있으면 버튼이 아예 서지 않고 확인 하나만 남는다. **`onCancel` 이 닫는 것 말고 따로 할 일이 있을 때만 준다.** 눌러도 창만 닫힌다면 X 버튼이 이미 그 일을 하므로(X 는 늘 서 있고, `persistent` 를 켜지 않았으면 백드롭·ESC 로도 닫힌다) 같은 일을 하는 버튼을 하나 더 세우지 않는다. 되묻는 모달이라고 해서 예외가 아니다 — "삭제할까요?" 에 답하지 않고 나가는 것은 X 로 충분하다. 그래서 서브 버튼이 설 자리는 **확인과 대등한 두 번째 선택지**다. 그때 글자는 `취소`·`닫기` 가 아니라 그 버튼이 하는 일의 이름이 된다. ```tsx ✅ { mainButtonLabel: '자동 취소', subButtonLabel: '수동 처리' } // 두 갈래를 고르게 한다 ❌ { mainButtonLabel: '삭제', subButtonLabel: '취소' } // X 가 이미 하는 일 ``` |
29
30
 
30
31
  #### Events
31
32
 
@@ -43,7 +43,36 @@ export interface SConfirmModalProps {
43
43
  * 같은 흰 면이고 테두리 유무만 다르다). 확인 버튼이 눌러야 할 것으로 안 읽히므로 권하지 않는다.
44
44
  */
45
45
  mainButtonOutline?: boolean;
46
- /** 서브(취소) 버튼 */
46
+ /**
47
+ * 메인 버튼을 눌리지 않게 한다. **기본값 `false`.**
48
+ *
49
+ * 모달 안에서 **아직 확정할 수 없을 때** 쓴다 — 동의 체크를 아직 안 했거나, `contentSlot` 의
50
+ * 입력이 덜 찼거나, 확인 뒤에 나갈 요청이 이미 나가는 중일 때.
51
+ *
52
+ * **왜 못 누르는지는 화면이 말해야 한다.** 흐려진 버튼만 남으면 사용자는 고장으로 읽는다 —
53
+ * 못 채운 것을 `bottomMessage` 나 `contentSlot` 안에서 함께 보인다.
54
+ *
55
+ * 서브(취소) 버튼은 따라 잠기지 않는다. 확정하지 못하는 상태일수록 **나가는 길은 열려 있어야**
56
+ * 하기 때문이다. 그래서 서브 버튼을 막는 prop 은 두지 않는다.
57
+ */
58
+ mainButtonDisabled?: boolean;
59
+ /**
60
+ * 서브 버튼에 찍히는 글자. **주지 않는 것이 기본이다** — 비어 있으면 버튼이 아예 서지 않고
61
+ * 확인 하나만 남는다.
62
+ *
63
+ * **`onCancel` 이 닫는 것 말고 따로 할 일이 있을 때만 준다.** 눌러도 창만 닫힌다면 X 버튼이
64
+ * 이미 그 일을 하므로(X 는 늘 서 있고, `persistent` 를 켜지 않았으면 백드롭·ESC 로도 닫힌다)
65
+ * 같은 일을 하는 버튼을 하나 더 세우지 않는다. 되묻는 모달이라고 해서 예외가 아니다 —
66
+ * "삭제할까요?" 에 답하지 않고 나가는 것은 X 로 충분하다.
67
+ *
68
+ * 그래서 서브 버튼이 설 자리는 **확인과 대등한 두 번째 선택지**다. 그때 글자는 `취소`·`닫기`
69
+ * 가 아니라 그 버튼이 하는 일의 이름이 된다.
70
+ *
71
+ * ```tsx
72
+ * ✅ { mainButtonLabel: '자동 취소', subButtonLabel: '수동 처리' } // 두 갈래를 고르게 한다
73
+ * ❌ { mainButtonLabel: '삭제', subButtonLabel: '취소' } // X 가 이미 하는 일
74
+ * ```
75
+ */
47
76
  subButtonLabel?: string;
48
77
  /** 확인 버튼 클릭 (sdOk) */
49
78
  onOk?: () => void;
@@ -52,4 +81,4 @@ export interface SConfirmModalProps {
52
81
  onClose?: () => void;
53
82
  }
54
83
  /** SConfirmModal — sd-confirm-modal 포팅. 아이콘+제목+메시지+태그+확인/취소 버튼. */
55
- export declare function SConfirmModal({ open, onOpenChange, persistent, type, modalTitle, titleClassName, topMessage, bottomMessage, tagLabel, tagShape, tagSize, tagColor, slotLabel, tagSlot, optionSlot, contentSlot, mainButtonLabel, mainButtonOutline, subButtonLabel, onOk, onCancel, onClose, }: SConfirmModalProps): import("react").JSX.Element;
84
+ export declare function SConfirmModal({ open, onOpenChange, persistent, type, modalTitle, titleClassName, topMessage, bottomMessage, tagLabel, tagShape, tagSize, tagColor, slotLabel, tagSlot, optionSlot, contentSlot, mainButtonLabel, mainButtonOutline, mainButtonDisabled, subButtonLabel, onOk, onCancel, onClose, }: SConfirmModalProps): import("react").JSX.Element;
@@ -101,10 +101,13 @@ export interface SGnbLauncher {
101
101
  /** 런처 **버튼**을 누름. 목록은 어차피 열리므로, 여는 것 말고 따로 할 일이 있을 때만 준다 */
102
102
  onClick?: () => void;
103
103
  /**
104
- * 목록에서 **서비스를 고름**. 고른 줄의 `value` 가 온다 — 그 값 하나로 앱이 갈 곳을 정한다.
105
- * 고르면 목록은 할 일을 끝낸 것이라 스스로 닫힌다(`onOpenChange(false)` 도 함께 온다).
104
+ * 목록에서 **서비스를 고름**. 고른 서비스 키가 온다.
105
+ *
106
+ * **여기서 화면을 옮기지 않는다** — 줄은 링크라 브라우저가 `href` 를 새 탭으로 이미 연다.
107
+ * 고른 것을 앱이 알아야 할 때만(기록 등) 준다. 고르면 목록은 할 일을 끝낸 것이라 스스로
108
+ * 닫힌다(`onOpenChange(false)` 도 함께 온다).
106
109
  */
107
- onSelect?: (value: string) => void;
110
+ onSelect?: (service: SLauncherService) => void;
108
111
  /**
109
112
  * 목록이 열리고 닫힐 때. 여닫는 것은 런처 버튼이 스스로 하므로 **그 사실을 알아야 할 때만** 준다
110
113
  * (연 김에 목록을 다시 불러오거나, 열려 있는 동안 다른 층을 접어 두는 화면).
@@ -1,5 +1,5 @@
1
1
  import { type HTMLAttributes, type ReactNode } from 'react';
2
- import { type SLauncherListBoxItem } from '../SLauncherListBox';
2
+ import { type SLauncherListBoxItem, type SLauncherService } from '../SLauncherListBox';
3
3
  import type { SGnbSystemPlacement } from '../SGnbSystem/gnbSystem.config';
4
4
  import { type SGnbType, type SGnbHeader, type SGnbColor, type SGnbMenuItem } from './gnb.config';
5
5
  /**
@@ -55,10 +55,13 @@ export interface SGnbLauncher {
55
55
  /** 런처 **버튼**을 누름. 목록은 어차피 열리므로, 여는 것 말고 따로 할 일이 있을 때만 준다 */
56
56
  onClick?: () => void;
57
57
  /**
58
- * 목록에서 **서비스를 고름**. 고른 줄의 `value` 가 온다 — 그 값 하나로 앱이 갈 곳을 정한다.
59
- * 고르면 목록은 할 일을 끝낸 것이라 스스로 닫힌다(`onOpenChange(false)` 도 함께 온다).
58
+ * 목록에서 **서비스를 고름**. 고른 서비스 키가 온다.
59
+ *
60
+ * **여기서 화면을 옮기지 않는다** — 줄은 링크라 브라우저가 `href` 를 새 탭으로 이미 연다.
61
+ * 고른 것을 앱이 알아야 할 때만(기록 등) 준다. 고르면 목록은 할 일을 끝낸 것이라 스스로
62
+ * 닫힌다(`onOpenChange(false)` 도 함께 온다).
60
63
  */
61
- onSelect?: (value: string) => void;
64
+ onSelect?: (service: SLauncherService) => void;
62
65
  /**
63
66
  * 목록이 열리고 닫힐 때. 여닫는 것은 런처 버튼이 스스로 하므로 **그 사실을 알아야 할 때만** 준다
64
67
  * (연 김에 목록을 다시 불러오거나, 열려 있는 동안 다른 층을 접어 두는 화면).
@@ -6,7 +6,12 @@ export declare const ICONS: {
6
6
  readonly account: (p: IconRenderProps) => import("react").JSX.Element;
7
7
  readonly add: (p: IconRenderProps) => import("react").JSX.Element;
8
8
  readonly alert: (p: IconRenderProps) => import("react").JSX.Element;
9
+ readonly alignCenter: (p: IconRenderProps) => import("react").JSX.Element;
9
10
  readonly alignKorean: (p: IconRenderProps) => import("react").JSX.Element;
11
+ readonly alignLeft: (p: IconRenderProps) => import("react").JSX.Element;
12
+ readonly alignMiddle: (p: IconRenderProps) => import("react").JSX.Element;
13
+ readonly alignRight: (p: IconRenderProps) => import("react").JSX.Element;
14
+ readonly alignTop: (p: IconRenderProps) => import("react").JSX.Element;
10
15
  readonly archive: (p: IconRenderProps) => import("react").JSX.Element;
11
16
  readonly arrowDown: (p: IconRenderProps) => import("react").JSX.Element;
12
17
  readonly arrowLeft: (p: IconRenderProps) => import("react").JSX.Element;
@@ -8,14 +8,14 @@
8
8
 
9
9
  | Prop | Type | Default | Description |
10
10
  |------|------|---------|-------------|
11
- | `items?` | `SLauncherListBoxItem[]` | — | 옮겨 있는 서비스 목록. 비어 있으면 아무것도 렌더하지 않는다 |
11
+ | `items?` | `SLauncherListBoxItem[]` | — | 목록에 세울 서비스. 비어 있으면 아무것도 렌더하지 않는다. **순서는 넘긴 대로가 아니라 카탈로그 순서다**(`LAUNCHER_SERVICES`) — 같은 런처가 앱마다 다른 순서로 뜨면 사용자가 매번 다시 찾는다. 같은 서비스를 두 번 넘기면 처음 것만 선다. |
12
12
  | `ariaLabel?` | `string` | — | 목록(ul)의 접근성 레이블 |
13
13
 
14
14
  #### Events
15
15
 
16
16
  | Event | Type | Description |
17
17
  |-------|------|-------------|
18
- | `onSelect` | `(value: string) => void` | 서비스를 고름. **줄마다 콜백을 두지 않는다** — 목록에서 일어나는 일은 "어느 서비스로 옮겨 가는가" 하나뿐이라, 고른 줄의 `value` 하나면 앱이 곳을 정할있다. `disabled` 줄은 눌리지 않으므로 여기로 오지 않는다. |
18
+ | `onSelect` | `(service: SLauncherService) => void` | 서비스를 고름. **가는 것은 콜백이 하지 않는다** — 줄이 링크라 브라우저가 `href` 탭으로 연다. 고른 것을 앱이 알아야 때만(기록·목록 닫기) 준다. 없는 줄(주소가 없거나 카탈로그가 아직 출시 전으로 든 서비스)은 눌리지 않으므로 여기로 오지 않는다. |
19
19
 
20
20
  ## Types
21
21
 
@@ -24,36 +24,39 @@
24
24
  ```ts
25
25
  /** 리스트박스의 한 줄 — 옮겨 갈 수 있는 서비스 하나 */
26
26
  export interface SLauncherListBoxItem {
27
- /** 목록 key */
28
- value: string;
29
27
  /**
30
- * 워드마크 오른쪽에 잇는 **서비스 표기** (slot) `WMS` · `Account` · `CRM` 같은 것.
31
- * 셀메이트 워드마크는 컴포넌트가 늘 그리므로 넘기지 않는다. 없으면 워드마크만 선다.
32
- *
33
- * **글자로 넘기면 크기·굵기·색을 주지 않는다** — 워드마크 옆에 서는 글자는 로고의 일부처럼
34
- * 읽혀야 해서 이 컴포넌트가 한 모습으로 정한다(14px bold · `fg.accentLight`).
35
- *
36
- * ```tsx
37
- * { value: 'wms', service: 'WMS', name: '셀메이트 WMS' }
38
- * ```
28
+ * 어느 서비스인가. **로고·표기·이름·상태 태그는 하나로 정해진다** 카탈로그
29
+ * (`LAUNCHER_SERVICE_CATALOG`)가 들고 있으므로 앱이 넘기지 않는다.
30
+ */
31
+ service: SLauncherService;
32
+ /**
33
+ * 새 탭에서 열 주소. **디자인 시스템은 주소를 들지 않는다** — 앱마다 QA·실서버가 달라
34
+ * 여기서 관리할 수 없다.
39
35
  *
40
- * 서비스가 자기 그래픽 마크를 가지면 svg·img 넘긴다 로고 높이에 맞춰 비율대로 들어가므로
41
- * 크기를 따로 주지 않는다.
36
+ * **주소를 주는 것이 "갈 있다"는 뜻이다.** 비우면 줄은 목록에 남되 눌리지 않고
37
+ * 물러난다 지금 보고 있는 서비스든, 이 앱이 계약하지 않은 서비스든 갈 곳이 없기는 같다.
38
+ * 못 가는 줄을 막는 별도의 prop 은 없다.
42
39
  */
43
- service?: ReactNode;
44
- /** 로고 아래 줄에 적히는 서비스 이름 */
45
- name?: string;
46
- /** 로고 오른쪽에 붙는 태그 텍스트. 없으면 태그가 붙지 않는다 */
47
- tag?: string;
48
- /** 태그 색. 기본 `'grey'` */
49
- tagColor?: STagColor;
50
- /** 태그 라벨 왼쪽 아이콘. 없으면 글자만 남는다 */
51
- tagIcon?: SIconName;
52
- /** 아직 갈 수 없는 서비스(출시 전 · 미계약). 눌리지 않고 로고·이름이 물러난다 */
53
- disabled?: boolean;
40
+ href?: string;
54
41
  }
55
42
  ```
56
43
 
44
+ ### SLauncherService
45
+
46
+ ```ts
47
+ export type SLauncherService = (typeof LAUNCHER_SERVICES)[number];
48
+ ```
49
+
50
+ ### LAUNCHER_SERVICES
51
+
52
+ ```ts
53
+ /**
54
+ * 런처에 설 수 있는 서비스 — **목록의 순서이기도 하다.**
55
+ * 앱이 넘긴 순서가 아니라 이 순서로 선다: 같은 런처가 앱마다 다른 순서로 뜨면 사용자가 매번 다시 찾는다.
56
+ */
57
+ export const LAUNCHER_SERVICES = ['sellmate', 'wms', 'account', 'crm'] as const;
58
+ ```
59
+
57
60
  ## Dependencies
58
61
 
59
62
  ### Used by
@@ -1,45 +1,38 @@
1
- import { type HTMLAttributes, type ReactNode } from 'react';
2
- import { type SIconName } from '../SIcon';
3
- import { type STagColor } from '../STag';
1
+ import { type HTMLAttributes } from 'react';
2
+ import { type SLauncherService } from './launcherListBox.config';
4
3
  /** 리스트박스의 한 줄 — 옮겨 갈 수 있는 서비스 하나 */
5
4
  export interface SLauncherListBoxItem {
6
- /** 목록 key */
7
- value: string;
8
5
  /**
9
- * 워드마크 오른쪽에 잇는 **서비스 표기** (slot) `WMS` · `Account` · `CRM` 같은 것.
10
- * 셀메이트 워드마크는 컴포넌트가 늘 그리므로 넘기지 않는다. 없으면 워드마크만 선다.
11
- *
12
- * **글자로 넘기면 크기·굵기·색을 주지 않는다** — 워드마크 옆에 서는 글자는 로고의 일부처럼
13
- * 읽혀야 해서 이 컴포넌트가 한 모습으로 정한다(14px bold · `fg.accentLight`).
14
- *
15
- * ```tsx
16
- * { value: 'wms', service: 'WMS', name: '셀메이트 WMS' }
17
- * ```
6
+ * 어느 서비스인가. **로고·표기·이름·상태 태그는 하나로 정해진다** 카탈로그
7
+ * (`LAUNCHER_SERVICE_CATALOG`)가 들고 있으므로 앱이 넘기지 않는다.
8
+ */
9
+ service: SLauncherService;
10
+ /**
11
+ * 새 탭에서 열 주소. **디자인 시스템은 주소를 들지 않는다** — 앱마다 QA·실서버가 달라
12
+ * 여기서 관리할 수 없다.
18
13
  *
19
- * 서비스가 자기 그래픽 마크를 가지면 svg·img 넘긴다 로고 높이에 맞춰 비율대로 들어가므로
20
- * 크기를 따로 주지 않는다.
14
+ * **주소를 주는 것이 "갈 있다"는 뜻이다.** 비우면 줄은 목록에 남되 눌리지 않고
15
+ * 물러난다 지금 보고 있는 서비스든, 이 앱이 계약하지 않은 서비스든 갈 곳이 없기는 같다.
16
+ * 못 가는 줄을 막는 별도의 prop 은 없다.
21
17
  */
22
- service?: ReactNode;
23
- /** 로고 아래 줄에 적히는 서비스 이름 */
24
- name?: string;
25
- /** 로고 오른쪽에 붙는 태그 텍스트. 없으면 태그가 붙지 않는다 */
26
- tag?: string;
27
- /** 태그 색. 기본 `'grey'` */
28
- tagColor?: STagColor;
29
- /** 태그 라벨 왼쪽 아이콘. 없으면 글자만 남는다 */
30
- tagIcon?: SIconName;
31
- /** 아직 갈 수 없는 서비스(출시 전 · 미계약). 눌리지 않고 로고·이름이 물러난다 */
32
- disabled?: boolean;
18
+ href?: string;
33
19
  }
34
20
  export interface SLauncherListBoxProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> {
35
- /** 옮겨 갈 수 있는 서비스 목록. 비어 있으면 아무것도 렌더하지 않는다 */
21
+ /**
22
+ * 목록에 세울 서비스. 비어 있으면 아무것도 렌더하지 않는다.
23
+ *
24
+ * **순서는 넘긴 대로가 아니라 카탈로그 순서다**(`LAUNCHER_SERVICES`) — 같은 런처가 앱마다
25
+ * 다른 순서로 뜨면 사용자가 매번 다시 찾는다. 같은 서비스를 두 번 넘기면 처음 것만 선다.
26
+ */
36
27
  items?: SLauncherListBoxItem[];
37
28
  /**
38
- * 서비스를 고름. **줄마다 콜백을 두지 않는다** — 목록에서 일어나는 일은 "어느 서비스로
39
- * 옮겨 가는가" 하나뿐이라, 고른 줄의 `value` 하나면 앱이 곳을 정할 있다.
40
- * `disabled` 줄은 눌리지 않으므로 여기로 오지 않는다.
29
+ * 서비스를 고름. **가는 것은 콜백이 하지 않는다** — 줄이 링크라 브라우저가 `href`
30
+ * 탭으로 연다. 고른 것을 앱이 알아야 때만(기록·목록 닫기) 준다.
31
+ *
32
+ * 갈 수 없는 줄(주소가 없거나 카탈로그가 아직 출시 전으로 든 서비스)은 눌리지 않으므로
33
+ * 여기로 오지 않는다.
41
34
  */
42
- onSelect?: (value: string) => void;
35
+ onSelect?: (service: SLauncherService) => void;
43
36
  /** 목록(ul)의 접근성 레이블 */
44
37
  ariaLabel?: string;
45
38
  }
@@ -48,18 +41,22 @@ export interface SLauncherListBoxProps extends Omit<HTMLAttributes<HTMLDivElemen
48
41
  *
49
42
  * 한 줄이 서비스 하나다: 위에 브랜드 로고(+ 상태 태그), 아래에 한글 이름.
50
43
  *
51
- * **셀메이트 워드마크는 컴포넌트가 그린다.** 앱은 오른쪽에 잇는 서비스 표기(`service` —
52
- * WMS · Account · CRM …)만 넘긴다. 표기를 **글자로 넘기면 모습도 이 컴포넌트가 정한다**
53
- * (14px bold · `fg.accentLight`) 워드마크 옆에 서는 글자는 로고의 일부처럼 읽혀야 해서
54
- * 서비스마다 달라지면 안 된다.
44
+ * **앱이 넘기는 것은 서비스 키와 주소 둘뿐이다.** 로고·서비스 표기·이름·상태 태그는 카탈로그
45
+ * (`LAUNCHER_SERVICE_CATALOG`)가 서비스마다 모습으로 정해 두므로 앱이 넘기지 않는다
46
+ * 넘기게 하면 같은 서비스가 앱마다 다른 이름·색·문구로 선다.
47
+ *
48
+ * **주소는 카탈로그에 두지 않는다.** 앱마다 QA·실서버 주소가 달라 디자인 시스템이 관리할 수 없다.
49
+ * 앱이 `href` 로 넘기면 줄이 링크가 되어 **새 탭에서 열린다** — 가운데 클릭·⌘클릭도 그대로 듣는다.
50
+ * **주소를 주는 것이 곧 "갈 수 있다"는 뜻이다**: 주지 않은 줄은 눌리지 않는다(지금 보고 있는
51
+ * 서비스이거나 이 앱이 계약하지 않은 서비스). 줄을 막는 별도의 prop 은 없다.
55
52
  *
56
53
  * **줄에 얹으면 "여기서 나간다"는 것이 드러난다** — 배경이 옅은 파랑으로 물들고 이름이 한 단계
57
54
  * 진해지며, 오른쪽 끝에 페이지 이동 아이콘이 떠오른다. 아이콘 자리는 늘 비워 두므로 얹었다 뗄 때
58
55
  * 글자가 밀리지 않는다.
59
56
  *
60
- * 아직 갈 수 없는 서비스(출시 · 미계약)는 `disabled` 둔다 눌리지 않고 얹어도 반응하지 않으며
61
- * 로고는 색이 빠지고 이름은 비활성 글자색이 된다. **목록에서 빼지 않는 것이 기본이다**: 서비스가
62
- * 있다는 사실과 아직 쓴다는 사실을 함께 알리는 것이 런처의 일이라, 이유를 적은 태그(`tag`)를 함께 붙인다.
57
+ * 갈 수 없는 줄은 눌리지 않고 로고는 색이 빠지며 이름은 비활성 글자색이 된다. **목록에서 빼지
58
+ * 않는 것이 기본이다**: 서비스가 있다는 사실과 지금은 간다는 사실을 함께 알리는 것이 런처의
59
+ * 일이다. 출시 전인 서비스는 카탈로그가 `출시예정` 태그와 함께 막아 두므로 앱이 일이 없다.
63
60
  *
64
61
  * **이 컴포넌트는 패널만 그린다.** 런처 버튼에 붙여 띄우는 것은 `SGnb` 의 `launcher` 가 맡으므로
65
62
  * (`launcher.items` 에 이 목록을 넘긴다), 앱이 직접 팝오버를 조립할 일은 없다.
@@ -1,2 +1,2 @@
1
1
  export { SLauncherListBox, type SLauncherListBoxProps, type SLauncherListBoxItem, } from './SLauncherListBox';
2
- export { LAUNCHER_LIST_BOX_LAYOUT, LAUNCHER_LIST_BOX_LOGO_HEIGHT, LAUNCHER_LIST_BOX_LOGO_SERVICE_GAP, LAUNCHER_LIST_BOX_TRAILING_ICON, LAUNCHER_LIST_BOX_WIDTH, } from './launcherListBox.config';
2
+ export { LAUNCHER_LIST_BOX_LAYOUT, LAUNCHER_LIST_BOX_LOGO_HEIGHT, LAUNCHER_LIST_BOX_LOGO_SERVICE_GAP, LAUNCHER_LIST_BOX_TRAILING_ICON, LAUNCHER_LIST_BOX_WIDTH, LAUNCHER_SERVICES, LAUNCHER_SERVICE_CATALOG, type SLauncherService, type SLauncherServiceEntry, } from './launcherListBox.config';