sellmate-design-system-react 9.0.0-beta.43 → 9.0.0-beta.45
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 +100 -1
- package/dist/components/SDraggableList/README.md +3 -3
- package/dist/components/SDraggableList/SDraggableList.d.ts +7 -3
- package/dist/components/SFooter/README.md +13 -8
- package/dist/components/SFooter/SFooter.d.ts +14 -9
- package/dist/components/SNumberInput/README.md +3 -2
- package/dist/components/SNumberInput/SNumberInput.d.ts +22 -2
- package/dist/components/SPopup/README.md +5 -6
- package/dist/components/SPopup/SPopup.d.ts +6 -7
- package/dist/components/SSelect/README.md +8 -0
- package/dist/components/SSelect/SSelect.d.ts +44 -0
- package/dist/components/SSelect/row-metrics.d.ts +60 -0
- package/dist/components/SSelect/select.config.d.ts +15 -0
- package/dist/components/STable/README.md +1 -1
- package/dist/components/STable/STable.d.ts +1 -1
- package/dist/components/STextarea/README.md +4 -1
- package/dist/components/STextarea/STextarea.d.ts +25 -2
- package/dist/index.cjs +455 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +455 -59
- package/dist/index.js.map +1 -1
- package/dist/llms-full.txt +137 -22
- package/dist/llms.txt +100 -1
- package/dist/styles.css +3 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -476,7 +476,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
476
476
|
| 여러 줄 텍스트를 받는다 | `STextarea` | §3-7-1 |
|
|
477
477
|
| 제목·굵게·목록·색 같은 **서식이 남아야 하는** 글을 받는다 | `SEditor` | §3-7-1 |
|
|
478
478
|
| 목록·결과를 검색어로 좁힌다 | `SSearchInput` | §3-7-1 |
|
|
479
|
-
| 숫자(수량·금액)를 받는다 | `SNumberInput` | |
|
|
479
|
+
| 숫자(수량·금액)를 받는다 | `SNumberInput` | §3-7-13 |
|
|
480
480
|
| 바코드를 스캔해 받는다 | `SBarcodeInput` | |
|
|
481
481
|
| 목록에서 하나 고르게 한다 | `SSelect` | §3-7-2 |
|
|
482
482
|
| 선택지를 항상 펼쳐 두고 하나 고르게 한다 | `SRadioGroup` | §3-7-2 |
|
|
@@ -774,6 +774,8 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
|
|
|
774
774
|
|
|
775
775
|
**하단 버튼을 본문(children)에 직접 두지 않는다.** 주 액션은 `button`, 보조 버튼은 `footerLeft` 로 넘긴다 — 푸터 배경·여백·양끝 분리가 컴포넌트 규칙대로 잡히는 자리다. `button` 은 클릭해도 **모달을 닫지 않으므로**(`onClick` 만 발화) 저장 API 응답을 보고 `modalRef.ok()` 로 닫으면 되고, 그 때문에 본문에 버튼을 따로 둘 이유가 없다. `footerLeft` 는 슬롯이라 `SButton` 을 직접 배치하며 `size="md"` 를 명시한다(§3-5-2).
|
|
776
776
|
|
|
777
|
+
**`button` 은 `SButton` 의 prop 을 그대로 받는다** — `icon`·`rightIcon`·`outline`·`disabled` 등을 함께 넘길 수 있다. 단 **`size` 는 타입에 없다**: 하단 액션 영역은 언제나 `md` 이고(§3-5-2) 예외가 없어 푸터가 고정한다. **`color` 는 주지 않는 것이 기본**이다 — 주지 않으면 주 액션 규칙대로 `primary` 가 되고, 그 버튼이 파괴적 액션일 때만 §3-5-3 에 따라 `danger` 를 준다. 카드(§3-7-8)의 `button` 도 같다.
|
|
778
|
+
|
|
777
779
|
**모달 안에서도 앱의 훅을 그냥 쓴다 — 단, 앱 루트에 `SModalOutlet` 이 있어야 한다 (§4-1).** outlet 이 있으면 명령형 모달이 앱 렌더 트리의 자식으로 그려지므로 `useQuery`·`useNavigate`·`useTheme` 같은 Context 기반 훅이 페이지에서와 똑같이 동작한다. **모달 컴포넌트를 Provider 로 다시 감싸지 않는다.** outlet 없이 띄우면 모달이 별도 React 루트로 떠서 Provider 가 하나도 닿지 않고, `No QueryClient set` 처럼 모달을 여는 순간에만 터진다.
|
|
778
780
|
|
|
779
781
|
#### 3-3-5. 닫기 경로 — `persistent` 기본값은 컴포넌트마다 다르다
|
|
@@ -1324,6 +1326,10 @@ const columns: STableColumn[] = [
|
|
|
1324
1326
|
|
|
1325
1327
|
폼 필드 둘은 **줄 수가 아니라 값의 성격으로** 갈린다. 값이 길어질 수 있는데 `SInput` 을 쓰면 사용자가 자기가 쓴 것을 다시 읽지 못한다 — 한 줄 안에서 좌우로 스크롤해야 하기 때문이다. 반대로 짧은 값에 `STextarea` 를 쓰면 빈 공간이 남아 입력량을 잘못 기대하게 한다.
|
|
1326
1328
|
|
|
1329
|
+
**`STextarea` 의 기본 높이는 두 줄이다.** 크기는 `size`(`'sm'` 기본 · `'md'`)로 정하고, 한 줄 필드와 나란히 놓이는 자리라면 `SInput` 과 같은 등급으로 맞춘다 — 등급이 글자·행간·안쪽 여백·모서리를 함께 정하므로 섞으면 같은 줄에서 어긋나 보인다. **더 높게 쓰려면 `rows` 를 준다**(줄 수). 높이를 `style` 이나 `textareaStyle` 로 직접 주지 않는다 — 등급이 정하는 값이고, 사용자가 모서리를 끌어 늘릴 수 있다. `rows` 를 두 줄 아래로 줄여도 등급이 정한 높이 밑으로는 내려가지 않는다. 한 줄만 받을 자리면 `SInput` 이다.
|
|
1330
|
+
|
|
1331
|
+
**입력한 만큼 늘어나게 하려면 `autogrow` 다.** 스크롤 대신 필드가 자라므로 쓴 글을 한눈에 다시 읽을 수 있다. 켜면 모서리를 끌어 크기를 바꾸는 손잡이는 사라진다 — 끌어 둔 높이를 다음 타이핑이 도로 계산하기 때문이다. **모달·드로어·카드처럼 아래에 버튼이 있는 자리에서는 `maxRows` 를 반드시 함께 준다** — 상한이 없으면 긴 글에서 필드가 계속 자라 그 버튼을 화면 밖으로 밀어낸다. 상한에 닿으면 그 안에서 스크롤한다. 페이지 본문처럼 아래로 밀려도 괜찮은 자리라면 상한 없이 써도 된다.
|
|
1332
|
+
|
|
1327
1333
|
`SEditor` 는 **서식이 값의 일부일 때만** 쓴다. 값을 HTML 문자열로 주고받으므로 저장·검색·비교가 평문보다 비싸고, 화면에 다시 보여줄 때도 HTML 로 렌더해야 한다. 서식이 필요 없는 메모·사유는 `STextarea` 다 — "입력창이 커 보여서" 고르는 컴포넌트가 아니다. 반대로 공지·안내문·상품 상세처럼 **작성자가 정한 강조와 목록이 그대로 보여야 하는 글**이면 `STextarea` 로는 표현할 수 없다.
|
|
1328
1334
|
|
|
1329
1335
|
`SEditor` 도 `SInput`·`STextarea` 와 같은 폼 필드다 — `label`·`hint`·`rules`·`errorMessage` 를 자기 prop 으로 받고 `SForm` 제출 검증에도 들어간다. 빈 문서는 빈 문자열로 나오므로 `required` 규칙이 그대로 걸린다. **툴바를 끄는 길은 없다** — 서식 입력이 필요 없는 자리라면 서식 없는 `SEditor` 가 아니라 `STextarea` 를 고른다.
|
|
@@ -1369,6 +1375,57 @@ const columns: STableColumn[] = [
|
|
|
1369
1375
|
- 카드 안에 상태 배지를 넣으려면 `tag` 슬롯에 `STag` 를 준다. 라벨 문자열에 "(추천)" 처럼 섞어 쓰지 않는다.
|
|
1370
1376
|
- **카드처럼 생겼다고 `SCard`/`SSectionHeaderCard` 로 감싸지 않는다.** `SRadioCard` 자체가 완결된 요소이고, 나열 간격은 `SRadioCardGroup` 의 `direction` 이 맞춘다 (§2-2).
|
|
1371
1377
|
|
|
1378
|
+
##### 옵션이 수백~수천 개면 — `onReachEnd`
|
|
1379
|
+
|
|
1380
|
+
**렌더는 걱정하지 않아도 된다.** `SSelect` 는 언제나 보이는 범위의 행만 그린다 — 켜고 끄는 prop 이 없고, 옵션이 5개든 5,000개든 여는 비용이 같다. 행 높이가 균일하다고 가정하지도 않으므로 계층 목록이나 큰 글씨가 섞인 라벨도 그대로 넘기면 된다.
|
|
1381
|
+
|
|
1382
|
+
**남는 부담은 전달이다.** 수천 건을 한 번에 받아 오는 것 자체가 무거우면 페이지 단위로 받는다.
|
|
1383
|
+
|
|
1384
|
+
- **`onReachEnd`** — 목록 끝이 가까워지면 불린다. 다음 페이지를 받아 `options` **뒤에 이어붙인다**(갈아끼우지 않는다). `hasMore` · `loading` 을 함께 준다. 없으면 마지막 페이지 뒤로도 계속 청하거나, 받는 중에 같은 페이지를 두 번 청한다.
|
|
1385
|
+
- **`onReachEnd` 를 쓰면 검색도 서버로 넘긴다** — `serverSearch` 를 켜고 `onSearchChange` 로 온 검색어에 맞는 목록을 내려준다. 켜지 않으면 검색이 "지금까지 받은 페이지" 안에서만 걸러져, 아직 받지 않은 항목은 검색해도 나오지 않는다. 검색어가 바뀌면 첫 페이지부터 다시 받는다.
|
|
1386
|
+
- **`reachEndThreshold` 는 한 페이지 크기보다 충분히 작게 잡는다.** 한 페이지가 드롭다운을 채우고도 이 문턱만큼 남기지 못하면 페이지가 도착하는 족족 다음 페이지를 다시 청해, 사용자가 스크롤하지 않아도 목록 전체를 받아 온다 — 페이징을 한 의미가 사라진다. 기본값이면 대개 그대로 두면 된다.
|
|
1387
|
+
- **늦게 온 응답이 최신 목록을 덮지 않게 한다.** 검색어나 페이지가 바뀌면 앞선 요청은 버려야 한다 — `useEffect` 의 cleanup 에서 취소 플래그를 세우는 것이 정석이다. 빠뜨리면 빠르게 지나간 검색어의 결과가 화면에 남는다. **무한 로딩에서 가장 흔히 새는 곳이다.**
|
|
1388
|
+
- **`showSelectAll` 은 함께 쓰지 않는다.** 아직 받지 않은 옵션은 고를 수 없어 "전체"가 거짓이 된다. 함께 주면 무시하고 개발 모드에서 경고한다.
|
|
1389
|
+
- **`valueAsPrimitive` 를 켜지 않는다.** 기본값(옵션 객체)이면 고른 값이 라벨을 함께 들고 다녀, 그 옵션이 지금 페이지나 검색 결과에서 빠져도 트리거에 이름이 그대로 남는다. 원시값만 들고 있으면 그 자리에 코드가 뜬다.
|
|
1390
|
+
- **계층 목록도 페이지로 받을 수 있다.** 단 이어붙일 때 **이미 있는 그룹의 `children` 에 이어야** 한다 — 같은 그룹을 새 항목으로 또 밀어 넣으면 목록에 같은 헤더가 두 번 뜬다.
|
|
1391
|
+
|
|
1392
|
+
```tsx
|
|
1393
|
+
const [query, setQuery] = useState('');
|
|
1394
|
+
const [page, setPage] = useState(0);
|
|
1395
|
+
const [options, setOptions] = useState<SSelectOption[]>([]);
|
|
1396
|
+
|
|
1397
|
+
useEffect(() => {
|
|
1398
|
+
let cancelled = false; // 늦게 온 이전 요청이 최신 목록을 덮지 않게
|
|
1399
|
+
setLoading(true);
|
|
1400
|
+
fetchClients(query, page).then(res => {
|
|
1401
|
+
if (cancelled) return;
|
|
1402
|
+
setOptions(prev => (page === 0 ? res.items : [...prev, ...res.items])); // 갈아끼우지 않고 이어붙인다
|
|
1403
|
+
setHasMore(res.hasMore);
|
|
1404
|
+
setLoading(false);
|
|
1405
|
+
});
|
|
1406
|
+
return () => {
|
|
1407
|
+
cancelled = true;
|
|
1408
|
+
};
|
|
1409
|
+
}, [query, page]);
|
|
1410
|
+
|
|
1411
|
+
<SSelect
|
|
1412
|
+
label="거래처"
|
|
1413
|
+
width="lg"
|
|
1414
|
+
options={options}
|
|
1415
|
+
hasMore={hasMore}
|
|
1416
|
+
loading={loading}
|
|
1417
|
+
onReachEnd={() => setPage(p => p + 1)}
|
|
1418
|
+
showSearch
|
|
1419
|
+
serverSearch
|
|
1420
|
+
onSearchChange={q => {
|
|
1421
|
+
setPage(0); // 새 검색은 첫 페이지부터
|
|
1422
|
+
setQuery(q);
|
|
1423
|
+
}}
|
|
1424
|
+
value={value}
|
|
1425
|
+
onValueChange={setValue}
|
|
1426
|
+
/>;
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1372
1429
|
#### 3-7-3. 켜고 끄는 셋 — SCheckbox vs SSwitch vs SToggle
|
|
1373
1430
|
|
|
1374
1431
|
셋 다 on/off 지만 **값이 언제 반영되는지**가 다르다. 이걸 틀리면 사용자가 저장 버튼을 찾다가 못 찾거나, 눌렀는데 반영이 안 돼 다시 누른다.
|
|
@@ -1462,6 +1519,23 @@ const [from, setFrom] = useState<string | null>(null);
|
|
|
1462
1519
|
❌ <SListItem title="일반 문의" selected /> {/* clickable 없으면 선택 표시가 안 나온다 */}
|
|
1463
1520
|
```
|
|
1464
1521
|
|
|
1522
|
+
##### 행 안에 컨트롤을 둘 때
|
|
1523
|
+
|
|
1524
|
+
`renderItem` 이 앱 몫이라 행 안에 입력·버튼이 들어오는 것은 예외가 아니다 — 순서를 정하는 목록은 대개 「몇 번째로 보낼지」를 숫자로도 받는다. 그냥 두면 된다.
|
|
1525
|
+
|
|
1526
|
+
- **행 선택은 옵트인이다.** `selectedKey`·`defaultSelectedKey`·`onSelectedKeyChange` 중 하나라도 주면 행이 `role="button"` 이 되어 클릭·Enter·Space 로 선택된다. 하나도 주지 않으면 행은 아무 상호작용도 갖지 않는다. **순서만 바꾸는 목록에 선택 prop 을 습관적으로 붙이지 않는다** — 붙이지 않아야 행 안의 컨트롤이 중첩 인터랙티브가 되지 않고 스크린리더에 그대로 노출된다.
|
|
1527
|
+
- **선택을 함께 쓰더라도 자손 컨트롤의 이벤트를 끊지 않는다.** 입력·버튼·링크에서 난 클릭과 Enter·Space 는 선택으로 새지 않는다 — 행의 제목 텍스트를 눌러 고르는 것만 선택으로 간다. `stopPropagation` 을 직접 넣을 자리가 아니다.
|
|
1528
|
+
|
|
1529
|
+
```tsx
|
|
1530
|
+
✅ <SDraggableList items={rows} getKey={r => r.id} {/* 선택을 안 쓰면 선택 prop 도 없다 */}
|
|
1531
|
+
renderItem={(row, _i, { onDragHandleMouseDown }) => (
|
|
1532
|
+
<SDraggableItem title={row.name} onDragHandleMouseDown={onDragHandleMouseDown}
|
|
1533
|
+
trailing={<SNumberInput width={72} value={row.order} onValueChange={…} />} />
|
|
1534
|
+
)} />
|
|
1535
|
+
|
|
1536
|
+
❌ <div onKeyDown={e => { e.stopPropagation(); … }}> {/* 리스트가 이미 걸러 준다 */}
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1465
1539
|
#### 3-7-7. 펼치는 셋 — SExpansionItem vs SExpansionList vs STree
|
|
1466
1540
|
|
|
1467
1541
|
| 판별 | 사용 |
|
|
@@ -1569,6 +1643,29 @@ const [selectedId, setSelectedId] = useState<string>();
|
|
|
1569
1643
|
❌ {loading ? <SCircleProgress indeterminate /> : <SImage src={url} />} {/* SImage 가 이미 한다 */}
|
|
1570
1644
|
```
|
|
1571
1645
|
|
|
1646
|
+
#### 3-7-13. 숫자 — SNumberInput 의 min·max 는 검증 경계다
|
|
1647
|
+
|
|
1648
|
+
`min`·`max` 는 `<input type="number">` 의 그것과 같다 — **값을 고쳐 쓰지 않는 검증 경계**다. 범위를 벗어나면 blur·제출 시 에러 상태가 서고, 사용자가 친 값은 그대로 남아 앱의 범위 가드에 도달한다.
|
|
1649
|
+
|
|
1650
|
+
- **문구를 앱이 대지 않아도 된다.** `rules` 도 `SForm` 도 없는 화면에서 `min`·`max` 만 주면 DS 가 「1~99 사이로 입력해 주세요.」 같은 기본 안내를 자동으로 붙인다. 범위 이탈이 아무 표시 없이 지나가는 경로가 없다.
|
|
1651
|
+
- **화면의 말로 바꾸려면 `rules` 를 준다.** 「우선순위는 1부터」처럼 그 화면에서만 통하는 문구가 있으면 `rules` 가 DS 기본 문구를 이긴다. 앱이 직접 준 `errorMessage` 도 마찬가지다.
|
|
1652
|
+
- **`SForm` 안에서는 범위 이탈이 제출을 막는다.** `rules` 를 따로 걸지 않아도 그렇다 — 네이티브 폼과 같다.
|
|
1653
|
+
- **빈 칸(`null`)은 범위 판정 대상이 아니다.** 필수 입력은 `min` 이 아니라 `rules` 로 막는다.
|
|
1654
|
+
- **경계로 붙여도 사용자가 놀라지 않는 값에서만 `clampOnBlur` 를 켠다.** 켜면 blur 시 값이 경계로 **말없이 바뀌고** `onValueChange` 로 되쏘아진다 — 그래서 앱의 범위 가드에는 범위 밖 값이 도달하지 않는다. 재고 조정 수량처럼 보정이 자연스러운 자리에만 쓰고, 업무 규칙(최소 주문 수량·우선순위 시작값)에는 쓰지 않는다.
|
|
1655
|
+
- 스테퍼(`showButton`)와 위/아래 화살표는 `clampOnBlur` 와 무관하게 언제나 `min`·`max` 를 한계로 삼는다.
|
|
1656
|
+
|
|
1657
|
+
```tsx
|
|
1658
|
+
✅ <SNumberInput label="우선순위" min={1} max={99} value={v} onValueChange={setV} />
|
|
1659
|
+
{/* 0 을 넣고 blur 하면 0 이 남고 에러가 선다 — 저장 가드의 v < 1 이 그대로 걸린다 */}
|
|
1660
|
+
|
|
1661
|
+
✅ <SNumberInput label="우선순위" min={1} max={99} value={v} onValueChange={setV}
|
|
1662
|
+
rules={[v => (typeof v === 'number' && (v < 1 || v > 99) ? '우선순위는 1부터 99까지입니다.' : true)]} />
|
|
1663
|
+
{/* 문구만 화면의 말로 바꾼다 */}
|
|
1664
|
+
|
|
1665
|
+
❌ <SNumberInput label="우선순위" min={1} clampOnBlur value={v} onValueChange={setV} />
|
|
1666
|
+
{/* 0 이 1 로 바뀐 뒤 도착하므로 저장 직전의 v < 1 가드는 영원히 걸리지 않는다 */}
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1572
1669
|
---
|
|
1573
1670
|
|
|
1574
1671
|
## 4. 페이지 레시피 — 표준 골격
|
|
@@ -2169,6 +2266,7 @@ export default function ProductDetailPage() {
|
|
|
2169
2266
|
- [ ] 날짜를 `YYYY-MM-DD` 로 표기했는가 (`toLocaleDateString()` ❌, §1-4)
|
|
2170
2267
|
- [ ] 필드 폭을 등급(`'xs' | 'sm' | 'md' | 'lg' | 'xl'`)으로 줬는가 — px 직접 지정 ❌ (§4-3)
|
|
2171
2268
|
- [ ] 조회 조건으로 쓰는 셀렉트·날짜·시간 피커에 `clearable` 을 줬는가, 그 상태가 `null` 을 담을 수 있는가 (§3-7-4 — 필수 입력 필드에는 켜지 않는다)
|
|
2269
|
+
- [ ] 서버에서 페이지 단위로 받는 `SSelect` 에 `onReachEnd` 와 `hasMore`·`loading`·`serverSearch` 를 함께 줬는가, 늦게 온 응답을 버리는 cleanup 이 있는가 (§3-7-2 — 렌더 최적화는 DS 가 알아서 한다)
|
|
2172
2270
|
- [ ] `SKeyValueTable` 의 짧은 행에 `tdColSpan` 을 주어 전체 열 수를 채웠는가 (§4-3 — 안 채우면 그 구간의 행 구분선이 끊긴다)
|
|
2173
2271
|
- [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
|
|
2174
2272
|
- [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
|
|
@@ -2179,6 +2277,7 @@ export default function ProductDetailPage() {
|
|
|
2179
2277
|
- [ ] 창을 띄울 때 §3-3-1 판별 순서를 따랐는가 (그 자체가 화면 → `SPopup` / 실행 여부만 확정 → `SModal.confirm` / 모달 안에서 작성 → `SActionModal`)
|
|
2180
2278
|
- [ ] 작업용 모달을 `SActionModal` + `SModal.create` 로 만들었는가 (직접 오버레이 ❌)
|
|
2181
2279
|
- [ ] 모달·드로어의 하단 버튼을 본문이 아니라 `button` · `footerLeft` prop 으로 넘겼는가 (§3-3-4), 카드 안에서 닫히는 액션도 같은 prop 으로 넘겼는가 (§3-7-8)
|
|
2280
|
+
- [ ] 아래에 버튼이 있는 자리(모달·드로어·카드)의 `STextarea autogrow` 에 `maxRows` 를 함께 줬는가 (§3-7-1 — 없으면 긴 글이 버튼을 화면 밖으로 밀어낸다)
|
|
2182
2281
|
- [ ] 앱 부트스트랩의 Provider 안쪽에 `<SModalOutlet />` 이 한 번 렌더되어 있는가 (§4-1 — 없으면 모달 안에서 앱 훅이 죽는다), 그 대신으로 모달 컴포넌트를 Provider 로 다시 감싸지 않았는가
|
|
2183
2282
|
- [ ] 고른 컴포넌트를 §2-0 의 제 층에 놓았는가 (요소를 `SPage` 에 직접 놓지 않았는가, 블록을 `div` 로 감싸지 않았는가)
|
|
2184
2283
|
- [ ] §2-0 포함 규칙을 지켰는가 (카드 안 카드 ❌, 표 셀 안 블록 ❌)
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
| `items` | `T[]` | — | 현재 순서대로 렌더링할 아이템 목록 |
|
|
26
26
|
| `getKey` | `(item: T, index: number) => string` | — | 아이템을 식별할 안정적인 key |
|
|
27
27
|
| `renderItem` | `(item: T, index: number, state: SDraggableListRenderState) => ReactNode` | — | 아이템 렌더링 함수 |
|
|
28
|
-
| `selectedKey?` | `string` | — | 외부에서 제어하는 selected 아이템 key |
|
|
29
|
-
| `defaultSelectedKey?` | `string` | — | 초기 selected 아이템 key |
|
|
28
|
+
| `selectedKey?` | `string` | — | 외부에서 제어하는 selected 아이템 key. 이 셋(`selectedKey`·`defaultSelectedKey`·`onSelectedKeyChange`) 중 하나라도 주면 **행 선택이 켜진다** — 행이 `role="button"` 이 되어 클릭·Enter·Space 로 선택된다. 아무것도 주지 않으면 행은 아무 상호작용도 갖지 않는다. |
|
|
29
|
+
| `defaultSelectedKey?` | `string` | — | 초기 selected 아이템 key. 주면 행 선택이 켜진다 (`selectedKey` 참조) |
|
|
30
30
|
| `getDisabled?` | `(item: T, index: number) => boolean` | — | disabled 아이템은 선택 및 드래그에서 제외 |
|
|
31
31
|
| `getDepth?` | `(item: T, index: number) => number` | — | 아이템의 중첩 단계. 기본값은 1 |
|
|
32
32
|
| `setDepth?` | `(item: T, depth: number) => T` | — | depth 변경이 필요한 드롭에서 다음 아이템을 만드는 함수 |
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
| Event | Type | Description |
|
|
41
41
|
|-------|------|-------------|
|
|
42
42
|
| `onChange` | `(items: T[]) => void` | 단독 리스트에서 드래그가 끝난 뒤 변경된 순서 |
|
|
43
|
-
| `onSelectedKeyChange` | `(key: string) => void` | selected 아이템이 변경될 때
|
|
43
|
+
| `onSelectedKeyChange` | `(key: string) => void` | selected 아이템이 변경될 때 호출. 주면 행 선택이 켜진다 (`selectedKey` 참조) |
|
|
44
44
|
|
|
45
45
|
### SDraggableProvider
|
|
46
46
|
|
|
@@ -16,11 +16,15 @@ export interface SDraggableListProps<T> extends Omit<SListProps, 'children' | 'o
|
|
|
16
16
|
renderItem: (item: T, index: number, state: SDraggableListRenderState) => ReactNode;
|
|
17
17
|
/** 단독 리스트에서 드래그가 끝난 뒤 변경된 순서 */
|
|
18
18
|
onChange?: (items: T[]) => void;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* 외부에서 제어하는 selected 아이템 key.
|
|
21
|
+
* 이 셋(`selectedKey`·`defaultSelectedKey`·`onSelectedKeyChange`) 중 하나라도 주면 **행 선택이 켜진다** —
|
|
22
|
+
* 행이 `role="button"` 이 되어 클릭·Enter·Space 로 선택된다. 아무것도 주지 않으면 행은 아무 상호작용도 갖지 않는다.
|
|
23
|
+
*/
|
|
20
24
|
selectedKey?: string;
|
|
21
|
-
/** 초기 selected 아이템 key */
|
|
25
|
+
/** 초기 selected 아이템 key. 주면 행 선택이 켜진다 (`selectedKey` 참조) */
|
|
22
26
|
defaultSelectedKey?: string;
|
|
23
|
-
/** selected 아이템이 변경될 때
|
|
27
|
+
/** selected 아이템이 변경될 때 호출. 주면 행 선택이 켜진다 (`selectedKey` 참조) */
|
|
24
28
|
onSelectedKeyChange?: (key: string) => void;
|
|
25
29
|
/** disabled 아이템은 선택 및 드래그에서 제외 */
|
|
26
30
|
getDisabled?: (item: T, index: number) => boolean;
|
|
@@ -27,14 +27,19 @@ export type SFooterBg = 'white' | 'grey';
|
|
|
27
27
|
### SFooterButton
|
|
28
28
|
|
|
29
29
|
```ts
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
/**
|
|
31
|
+
* footer 우측 기본 액션 버튼 설정 — `SButton` 의 prop 을 그대로 받는다.
|
|
32
|
+
*
|
|
33
|
+
* 골라 담은 부분집합으로 두지 않는 이유: `SButton` 에 prop 이 늘 때마다 이 목록만 조용히
|
|
34
|
+
* 뒤처져, 아이콘 하나 붙이려 해도 footer 에서는 길이 없는 상태가 된다.
|
|
35
|
+
*
|
|
36
|
+
* **`size` 만 뺀다.** 버튼 크기는 놓이는 위치가 정하고 하단 액션 영역은 언제나 `md` 다
|
|
37
|
+
* (AGENTS.md §3-5-2 — 예외가 없는 규칙이라 문서가 아니라 타입이 지킨다).
|
|
38
|
+
*
|
|
39
|
+
* `label`·`color` 는 주지 않으면 footer 가 주 액션 기본값(`'확인'`·`'primary'`)으로 잡는다.
|
|
40
|
+
* 파괴적 액션이라 `danger` 가 필요한 경우는 열어 둔다 (§3-5-3).
|
|
41
|
+
*/
|
|
42
|
+
export type SFooterButton = Omit<SButtonProps, 'size'>;
|
|
38
43
|
```
|
|
39
44
|
|
|
40
45
|
## Dependencies
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode } from 'react';
|
|
2
|
-
import { type
|
|
2
|
+
import { type SButtonProps } from '../SButton';
|
|
3
3
|
export type SFooterBg = 'white' | 'grey';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
/**
|
|
5
|
+
* footer 우측 기본 액션 버튼 설정 — `SButton` 의 prop 을 그대로 받는다.
|
|
6
|
+
*
|
|
7
|
+
* 골라 담은 부분집합으로 두지 않는 이유: `SButton` 에 prop 이 늘 때마다 이 목록만 조용히
|
|
8
|
+
* 뒤처져, 아이콘 하나 붙이려 해도 footer 에서는 길이 없는 상태가 된다.
|
|
9
|
+
*
|
|
10
|
+
* **`size` 만 뺀다.** 버튼 크기는 놓이는 위치가 정하고 하단 액션 영역은 언제나 `md` 다
|
|
11
|
+
* (AGENTS.md §3-5-2 — 예외가 없는 규칙이라 문서가 아니라 타입이 지킨다).
|
|
12
|
+
*
|
|
13
|
+
* `label`·`color` 는 주지 않으면 footer 가 주 액션 기본값(`'확인'`·`'primary'`)으로 잡는다.
|
|
14
|
+
* 파괴적 액션이라 `danger` 가 필요한 경우는 열어 둔다 (§3-5-3).
|
|
15
|
+
*/
|
|
16
|
+
export type SFooterButton = Omit<SButtonProps, 'size'>;
|
|
12
17
|
/**
|
|
13
18
|
* @internal
|
|
14
19
|
* 모달·드로어·팝업·카드 하단 영역 공통 props.
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
|------|------|---------|-------------|
|
|
11
11
|
| `value?` | `string \| number \| null` | — | 입력 값 |
|
|
12
12
|
| `size?` | `SNumberInputSize` | `'sm'` | 크기 |
|
|
13
|
-
| `min?` | `number` | `Number.NEGATIVE_INFINITY` | 최솟값 |
|
|
14
|
-
| `max?` | `number` | `Number.POSITIVE_INFINITY` | 최댓값 |
|
|
13
|
+
| `min?` | `number` | `Number.NEGATIVE_INFINITY` | 최솟값 — **검증 경계**다 (`<input type="number">` 의 `min` 과 같다). 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다. 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다. |
|
|
14
|
+
| `max?` | `number` | `Number.POSITIVE_INFINITY` | 최댓값 — **검증 경계**다 (`<input type="number">` 의 `max` 와 같다). 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다. 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다. |
|
|
15
|
+
| `clampOnBlur?` | `boolean` | `false` | blur 시 값을 `[min, max]` 경계로 끌어당길지. 기본 `false`. 기본값에서는 사용자가 친 값이 그대로 보존되고, 범위를 벗어나면 에러 상태로 알린다 — 그래야 소비자의 범위 검증에 실제 입력값이 도달한다. `true` 로 켜면 범위 밖 값이 경계값으로 **말없이 바뀌고** `onValueChange` 로 되쏘아진다 (`min={1}` 인 칸에 `0` 을 넣고 blur 하면 `onValueChange(1)` 이 발화한다). 경계로 붙여도 사용자가 놀라지 않는 값에만 켠다. 켜면 범위 에러는 서지 않는다 — 고쳐 쓴 값은 언제나 범위 안이기 때문이다. |
|
|
15
16
|
| `step?` | `number` | `1` | 스텝 단위 |
|
|
16
17
|
| `showButton?` | `boolean` | `false` | 증감 버튼 표시 |
|
|
17
18
|
| `allowDecimal?` | `boolean` | `false` | 소수점 입력 허용 |
|
|
@@ -13,10 +13,30 @@ export interface SNumberInputProps {
|
|
|
13
13
|
onValueChange?: (value: number | null) => void;
|
|
14
14
|
/** 크기 */
|
|
15
15
|
size?: SNumberInputSize;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* 최솟값 — **검증 경계**다 (`<input type="number">` 의 `min` 과 같다).
|
|
18
|
+
* 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다.
|
|
19
|
+
* 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다.
|
|
20
|
+
*/
|
|
17
21
|
min?: number;
|
|
18
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* 최댓값 — **검증 경계**다 (`<input type="number">` 의 `max` 와 같다).
|
|
24
|
+
* 값을 고쳐 쓰지 않는다. 벗어나면 blur·제출 시 에러 상태가 서고 안내 문구가 자동으로 붙는다.
|
|
25
|
+
* 스테퍼(`showButton`)와 위/아래 화살표는 이 값을 한계로 삼는다.
|
|
26
|
+
*/
|
|
19
27
|
max?: number;
|
|
28
|
+
/**
|
|
29
|
+
* blur 시 값을 `[min, max]` 경계로 끌어당길지. 기본 `false`.
|
|
30
|
+
*
|
|
31
|
+
* 기본값에서는 사용자가 친 값이 그대로 보존되고, 범위를 벗어나면 에러 상태로 알린다 —
|
|
32
|
+
* 그래야 소비자의 범위 검증에 실제 입력값이 도달한다.
|
|
33
|
+
*
|
|
34
|
+
* `true` 로 켜면 범위 밖 값이 경계값으로 **말없이 바뀌고** `onValueChange` 로 되쏘아진다
|
|
35
|
+
* (`min={1}` 인 칸에 `0` 을 넣고 blur 하면 `onValueChange(1)` 이 발화한다).
|
|
36
|
+
* 경계로 붙여도 사용자가 놀라지 않는 값에만 켠다. 켜면 범위 에러는 서지 않는다 —
|
|
37
|
+
* 고쳐 쓴 값은 언제나 범위 안이기 때문이다.
|
|
38
|
+
*/
|
|
39
|
+
clampOnBlur?: boolean;
|
|
20
40
|
/** 스텝 단위 */
|
|
21
41
|
step?: number;
|
|
22
42
|
/** 증감 버튼 표시 */
|
|
@@ -35,12 +35,11 @@ export type SPopupType = 'default' | 'light';
|
|
|
35
35
|
### SPopupSubmitButton
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
38
|
+
/**
|
|
39
|
+
* 확인 버튼 설정 — `SButton` 의 prop 을 그대로 받되 `onClick` 만 뺀다.
|
|
40
|
+
* 클릭은 `onSubmit` 이 전담하므로, 여기로도 받으면 조용히 덮어써지는 prop 이 생긴다.
|
|
41
|
+
*/
|
|
42
|
+
export type SPopupSubmitButton = Omit<SFooterButton, 'onClick'>;
|
|
44
43
|
```
|
|
45
44
|
|
|
46
45
|
### SPopupBodyPadding
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { type ReactNode, type CSSProperties } from 'react';
|
|
2
|
-
import { type
|
|
2
|
+
import { type SFooterButton } from '../SFooter';
|
|
3
3
|
export type SPopupType = 'default' | 'light';
|
|
4
4
|
export type SPopupBodyPadding = 'default' | 'none';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
5
|
+
/**
|
|
6
|
+
* 확인 버튼 설정 — `SButton` 의 prop 을 그대로 받되 `onClick` 만 뺀다.
|
|
7
|
+
* 클릭은 `onSubmit` 이 전담하므로, 여기로도 받으면 조용히 덮어써지는 prop 이 생긴다.
|
|
8
|
+
*/
|
|
9
|
+
export type SPopupSubmitButton = Omit<SFooterButton, 'onClick'>;
|
|
11
10
|
export interface SPopupProps {
|
|
12
11
|
/** 헤더 제목 */
|
|
13
12
|
popupTitle?: string;
|
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
| `dropdownHeight?` | `string` | `'640px'` | 드롭다운 최대 높이. 기본 640px — 창이 이보다 작으면 창 안에 남는 만큼까지만 채워진다 |
|
|
26
26
|
| `dropdownWidth?` | `string` | — | 드롭다운 너비 (없으면 트리거 너비). 창보다 넓게 줘도 창 폭까지만 벌어진다. |
|
|
27
27
|
| `maxDropdownWidth?` | `string` | `'640px'` | 드롭다운 최대 너비. 창이 이보다 좁으면 창 폭이 상한이 된다. |
|
|
28
|
+
| `virtualBuffer?` | `number` | `DEFAULT_VIRTUAL_BUFFER` | 화면 위·아래로 더 그려둘 여유 행 수 — 빠르게 스크롤할 때 빈 칸이 보이지 않게 한다. 목록은 **언제나 보이는 범위만 렌더한다**(끄는 prop 은 없다). 그래서 여는 비용이 옵션 수와 무관하고, 이 값만이 DOM 에 남는 행 수를 정한다 — 한 화면 분량 + 앞뒤로 이만큼. |
|
|
29
|
+
| `reachEndThreshold?` | `number` | `DEFAULT_REACH_END_THRESHOLD` | 목록 끝에서 이만큼 행이 남았을 때 `onReachEnd` 를 부른다 — 스크롤이 바닥에 닿기 전에 미리 받아 둔다. **한 페이지 크기보다 충분히 작게 잡는다.** 한 페이지가 드롭다운을 채우고도 이 문턱만큼 남기지 못하면, 페이지가 도착하는 족족 다음 페이지를 다시 청하게 된다 — 사용자가 스크롤을 하지 않아도 목록 전체를 받아 오므로 페이징을 한 의미가 사라진다. (한 페이지 50개 · 드롭다운에 20행이 보인다면 남는 것은 30행이므로, 문턱은 그보다 작아야 한다) |
|
|
30
|
+
| `hasMore?` | `boolean` | `false` | 더 받아올 페이지가 남았는가. `false` 면 `onReachEnd` 를 더 부르지 않는다 |
|
|
31
|
+
| `loading?` | `boolean` | `false` | 다음 페이지를 받는 중 — 목록 하단에 로딩 표시를 내고, 그동안 `onReachEnd` 를 다시 부르지 않는다 |
|
|
32
|
+
| `serverSearch?` | `boolean` | `false` | 검색을 서버로 넘긴다 — 내부 필터링을 하지 않고 `options` 를 받은 그대로 보여준다. `onSearchChange` 로 온 검색어에 맞는 목록을 소비 앱이 다시 내려줘야 한다. 켜면 검색바가 옵션 수와 무관하게 항상 나온다 — 검색 결과가 줄었다고 검색바가 사라지면 검색어를 지울 수단이 없어지기 때문이다. |
|
|
33
|
+
| `searchDebounce?` | `number` | `DEFAULT_SEARCH_DEBOUNCE` | `serverSearch` 에서 검색어를 서버로 넘기기 전 기다리는 시간(ms) |
|
|
28
34
|
| `label?` | `string` | — | |
|
|
29
35
|
| `labelWidth?` | `number \| string` | — | |
|
|
30
36
|
| `icon?` | `SIconName` | — | 레이블 영역 아이콘 |
|
|
@@ -44,6 +50,8 @@
|
|
|
44
50
|
|-------|------|-------------|
|
|
45
51
|
| `onValueChange` | `(value: any) => void` | 값 변경. 기본은 SSelectOption(들)이 오고, `valueAsPrimitive` 면 원시값이 온다 |
|
|
46
52
|
| `onOpenChange` | `(open: boolean) => void` | 열림/닫힘 변경 (sdDropDownShow) |
|
|
53
|
+
| `onReachEnd` | `() => void` | 목록 끝이 가까워지면 부른다 — 다음 페이지를 받아 `options` 뒤에 이어붙이라는 신호다. `hasMore` 가 `false` 이거나 `loading` 중이면 부르지 않고, 같은 목록 길이로 두 번 부르지 않는다. **`options` 는 갈아끼우지 말고 이어붙인다.** depth 타입이면 이미 있는 그룹의 `children` 에 이어야 한다 — 같은 그룹을 새 항목으로 또 밀어 넣으면 목록에 같은 헤더가 두 번 뜬다. 아직 받지 않은 옵션은 라벨을 알 수 없다. 그래서 이 prop 을 쓰는 화면은 `valueAsPrimitive` 를 켜지 않는 편이 안전하다 — 기본값(옵션 객체)이면 선택값이 라벨을 함께 들고 다녀서, 그 옵션이 목록에서 사라져도 트리거에 이름이 그대로 남는다. |
|
|
54
|
+
| `onSearchChange` | `(query: string) => void` | 검색어 변경. `serverSearch` 면 `searchDebounce` 만큼 묶어서 온다 |
|
|
47
55
|
|
|
48
56
|
#### Methods (ref)
|
|
49
57
|
|
|
@@ -63,6 +63,50 @@ export interface SSelectProps {
|
|
|
63
63
|
dropdownWidth?: string;
|
|
64
64
|
/** 드롭다운 최대 너비. 창이 이보다 좁으면 창 폭이 상한이 된다. */
|
|
65
65
|
maxDropdownWidth?: string;
|
|
66
|
+
/**
|
|
67
|
+
* 화면 위·아래로 더 그려둘 여유 행 수 — 빠르게 스크롤할 때 빈 칸이 보이지 않게 한다.
|
|
68
|
+
*
|
|
69
|
+
* 목록은 **언제나 보이는 범위만 렌더한다**(끄는 prop 은 없다). 그래서 여는 비용이 옵션
|
|
70
|
+
* 수와 무관하고, 이 값만이 DOM 에 남는 행 수를 정한다 — 한 화면 분량 + 앞뒤로 이만큼.
|
|
71
|
+
*/
|
|
72
|
+
virtualBuffer?: number;
|
|
73
|
+
/**
|
|
74
|
+
* 목록 끝이 가까워지면 부른다 — 다음 페이지를 받아 `options` 뒤에 이어붙이라는 신호다.
|
|
75
|
+
* `hasMore` 가 `false` 이거나 `loading` 중이면 부르지 않고, 같은 목록 길이로 두 번 부르지 않는다.
|
|
76
|
+
*
|
|
77
|
+
* **`options` 는 갈아끼우지 말고 이어붙인다.** depth 타입이면 이미 있는 그룹의 `children`
|
|
78
|
+
* 에 이어야 한다 — 같은 그룹을 새 항목으로 또 밀어 넣으면 목록에 같은 헤더가 두 번 뜬다.
|
|
79
|
+
*
|
|
80
|
+
* 아직 받지 않은 옵션은 라벨을 알 수 없다. 그래서 이 prop 을 쓰는 화면은
|
|
81
|
+
* `valueAsPrimitive` 를 켜지 않는 편이 안전하다 — 기본값(옵션 객체)이면 선택값이 라벨을
|
|
82
|
+
* 함께 들고 다녀서, 그 옵션이 목록에서 사라져도 트리거에 이름이 그대로 남는다.
|
|
83
|
+
*/
|
|
84
|
+
onReachEnd?: () => void;
|
|
85
|
+
/**
|
|
86
|
+
* 목록 끝에서 이만큼 행이 남았을 때 `onReachEnd` 를 부른다 — 스크롤이 바닥에 닿기 전에 미리 받아 둔다.
|
|
87
|
+
*
|
|
88
|
+
* **한 페이지 크기보다 충분히 작게 잡는다.** 한 페이지가 드롭다운을 채우고도 이 문턱만큼
|
|
89
|
+
* 남기지 못하면, 페이지가 도착하는 족족 다음 페이지를 다시 청하게 된다 — 사용자가 스크롤을
|
|
90
|
+
* 하지 않아도 목록 전체를 받아 오므로 페이징을 한 의미가 사라진다.
|
|
91
|
+
* (한 페이지 50개 · 드롭다운에 20행이 보인다면 남는 것은 30행이므로, 문턱은 그보다 작아야 한다)
|
|
92
|
+
*/
|
|
93
|
+
reachEndThreshold?: number;
|
|
94
|
+
/** 더 받아올 페이지가 남았는가. `false` 면 `onReachEnd` 를 더 부르지 않는다 */
|
|
95
|
+
hasMore?: boolean;
|
|
96
|
+
/** 다음 페이지를 받는 중 — 목록 하단에 로딩 표시를 내고, 그동안 `onReachEnd` 를 다시 부르지 않는다 */
|
|
97
|
+
loading?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* 검색을 서버로 넘긴다 — 내부 필터링을 하지 않고 `options` 를 받은 그대로 보여준다.
|
|
100
|
+
* `onSearchChange` 로 온 검색어에 맞는 목록을 소비 앱이 다시 내려줘야 한다.
|
|
101
|
+
*
|
|
102
|
+
* 켜면 검색바가 옵션 수와 무관하게 항상 나온다 — 검색 결과가 줄었다고 검색바가 사라지면
|
|
103
|
+
* 검색어를 지울 수단이 없어지기 때문이다.
|
|
104
|
+
*/
|
|
105
|
+
serverSearch?: boolean;
|
|
106
|
+
/** 검색어 변경. `serverSearch` 면 `searchDebounce` 만큼 묶어서 온다 */
|
|
107
|
+
onSearchChange?: (query: string) => void;
|
|
108
|
+
/** `serverSearch` 에서 검색어를 서버로 넘기기 전 기다리는 시간(ms) */
|
|
109
|
+
searchDebounce?: number;
|
|
66
110
|
label?: string;
|
|
67
111
|
labelWidth?: number | string;
|
|
68
112
|
/** 레이블 영역 아이콘 */
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 가변 행 높이 가상 스크롤의 계산부 — DOM 을 모르는 순수 자료구조다.
|
|
3
|
+
*
|
|
4
|
+
* 아직 그려지지 않은 행은 추정치로 두고, 화면에 나온 행은 실측으로 덮는다. 그래서
|
|
5
|
+
* "모든 행이 같은 높이"라는 가정 없이도 스크롤 위치 ↔ 행 인덱스를 오갈 수 있다 —
|
|
6
|
+
* depth 그룹 헤더처럼 리프와 높이가 다른 행이 섞여도 어긋나지 않는다.
|
|
7
|
+
* (Quasar QVirtualScroll 의 `virtual-scroll-item-size` 가 "추정치일 뿐"인 것과 같은 모델)
|
|
8
|
+
*
|
|
9
|
+
* 수천 행에서 렌더마다 새 배열을 만들면 그 자체가 비용이라, 제자리에서 고친다.
|
|
10
|
+
* 소유자는 ref 하나로 들고 있고, 화면에 반영할 필요가 있을 때만 리렌더를 건다.
|
|
11
|
+
*/
|
|
12
|
+
export interface RowMetrics {
|
|
13
|
+
/** 행별 높이. 실측 전에는 `estimate` 가 들어 있다 */
|
|
14
|
+
sizes: number[];
|
|
15
|
+
/** 실측을 마친 행인지 — 추정치를 평균에 섞지 않으려고 구분한다 */
|
|
16
|
+
measured: boolean[];
|
|
17
|
+
/** `offsets[i]` = 0..i-1 행 높이의 합. 길이는 `sizes.length + 1` 이라 마지막이 전체 높이다 */
|
|
18
|
+
offsets: number[];
|
|
19
|
+
/** 아직 안 그려진 행에 쓸 높이. 실측이 쌓일수록 그 평균으로 수렴한다 */
|
|
20
|
+
estimate: number;
|
|
21
|
+
/** 추정치 갱신용 누적 — 평균을 매번 다시 훑지 않으려고 합과 개수를 들고 있는다 */
|
|
22
|
+
measuredCount: number;
|
|
23
|
+
measuredTotal: number;
|
|
24
|
+
/** 이 인덱스부터 `offsets` 가 낡았다. `Infinity` 면 최신 */
|
|
25
|
+
dirtyFrom: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function createRowMetrics(count: number, estimate: number): RowMetrics;
|
|
28
|
+
/**
|
|
29
|
+
* 목록 길이가 바뀌었을 때 표를 맞춘다.
|
|
30
|
+
*
|
|
31
|
+
* 늘어난 만큼은 추정치로 채우고 **앞쪽 실측은 그대로 둔다** — 무한 로딩은 뒤에 이어붙이는
|
|
32
|
+
* 것이라, 여기서 표를 새로 만들면 이미 잰 높이를 버리고 스크롤이 튄다. 줄어들 때는 잘라낸다
|
|
33
|
+
* (검색으로 목록이 좁아지는 경우인데, 그때는 같은 인덱스가 다른 행이므로 실측도 함께 버린다).
|
|
34
|
+
*/
|
|
35
|
+
export declare function resizeRowMetrics(metrics: RowMetrics, count: number): void;
|
|
36
|
+
/** 목록이 통째로 다른 것으로 갈렸을 때 — 실측을 전부 버리고 추정치만 남긴다 */
|
|
37
|
+
export declare function resetRowMetrics(metrics: RowMetrics, count: number): void;
|
|
38
|
+
/**
|
|
39
|
+
* 실측 높이를 반영한다. 값이 실제로 달라졌으면 `true`.
|
|
40
|
+
*
|
|
41
|
+
* 추정치도 실측 평균으로 함께 끌어당긴다 — 한 번도 안 그려진 뒤쪽 수천 행의 높이가
|
|
42
|
+
* 그만큼 사실에 가까워져, 스크롤바 길이와 "끝까지 내렸을 때"의 위치가 맞는다.
|
|
43
|
+
* 다만 1px 미만의 흔들림으로 표 전체를 다시 세우지는 않는다.
|
|
44
|
+
*/
|
|
45
|
+
export declare function setRowSize(metrics: RowMetrics, index: number, size: number): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* 낡은 구간의 누적 오프셋을 다시 센다. 이미 최신이면 아무 일도 하지 않는다.
|
|
48
|
+
* 바뀐 지점 앞은 그대로 두고 거기서부터만 이어 세므로, 아래쪽 행을 재도 전체를 훑지 않는다.
|
|
49
|
+
*/
|
|
50
|
+
export declare function rebuildOffsets(metrics: RowMetrics): void;
|
|
51
|
+
/** 목록 전체 높이 */
|
|
52
|
+
export declare function totalSize(metrics: RowMetrics): number;
|
|
53
|
+
/** `index` 번 행의 위쪽 끝 y */
|
|
54
|
+
export declare function offsetOf(metrics: RowMetrics, index: number): number;
|
|
55
|
+
export declare function sizeOf(metrics: RowMetrics, index: number): number;
|
|
56
|
+
/**
|
|
57
|
+
* y 좌표에 놓인 행의 인덱스 (이진 탐색). 목록 끝을 넘으면 마지막 행을 준다.
|
|
58
|
+
* 수천 행에서 스크롤마다 훑으면 그게 곧 렉이라 선형 탐색을 쓰지 않는다.
|
|
59
|
+
*/
|
|
60
|
+
export declare function indexAtOffset(metrics: RowMetrics, y: number): number;
|
|
@@ -29,3 +29,18 @@ export declare function collectGroupLeafValues(flat: FlatOption[], groupIndex: n
|
|
|
29
29
|
export declare const isHtmlLabel: (label: string) => boolean;
|
|
30
30
|
/** HTML 라벨에서 태그를 제거한 텍스트 (검색 매칭용, 원본 extractText) */
|
|
31
31
|
export declare const extractText: (html: string) => string;
|
|
32
|
+
/**
|
|
33
|
+
* 아직 한 번도 그려지지 않은 행의 **추정** 높이(px). 리프 옵션은 세로 패딩 4px + 행간 20px
|
|
34
|
+
* 이라 보통 이 값이지만, 그룹 헤더나 큰 글씨가 섞인 라벨은 다르다 — 화면에 나온 행은
|
|
35
|
+
* 실측으로 덮이므로(`row-metrics.ts`) 이 값은 처음 스크롤바 길이를 잡는 출발점일 뿐이다.
|
|
36
|
+
*/
|
|
37
|
+
export declare const OPTION_ROW_HEIGHT = 28;
|
|
38
|
+
/**
|
|
39
|
+
* 화면 위·아래로 더 그려둘 기본 여유 행 수.
|
|
40
|
+
* 640px 드롭다운이 20행 남짓 보이므로, 앞뒤 10행이면 한 화면을 굴리는 동안 빈 칸이 보이지 않는다.
|
|
41
|
+
*/
|
|
42
|
+
export declare const DEFAULT_VIRTUAL_BUFFER = 10;
|
|
43
|
+
/** 목록 끝에서 이만큼 행이 남았을 때 다음 페이지를 청하는 기본값 */
|
|
44
|
+
export declare const DEFAULT_REACH_END_THRESHOLD = 10;
|
|
45
|
+
/** `serverSearch` 에서 검색어를 서버로 넘기기 전 기다리는 기본 시간(ms) */
|
|
46
|
+
export declare const DEFAULT_SEARCH_DEBOUNCE = 300;
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
| `emptySlot?` | `ReactNode` | — | 데이터가 없을 때 body 영역 전체를 대체하는 슬롯. 지정하면 `emptyLabel` 대신 이 콘텐츠가 헤더 아래 영역을 채우며, 버튼 등 인터랙션도 동작한다. |
|
|
26
26
|
| `loading?` | `boolean` | `false` | |
|
|
27
27
|
| `dense?` | `boolean` | `false` | 행 높이를 좁게 (세로 여백만 줄인다 — 좌우 패딩은 그대로). **시작 밀도이자 밀도 토글의 스위치다.** 켜면 하단 바 우측에 `좁게 보기` · `넓게 보기` 토글이 붙는다 — 페이지네이션이 없으면 이 바를 토글만 담아 그린다. 누른 뒤의 밀도는 테이블이 내부 상태로 들고 가므로 `onDenseChange` 를 받지 않아도 토글은 동작하고, 넓게 본 뒤에도 토글은 그대로 남는다(붙일지는 이 prop 이 정한다). 이 prop 값이 바뀌면 내부 밀도도 그 값으로 맞춰진다. |
|
|
28
|
-
| `hoverable?` | `boolean` | `true` |
|
|
28
|
+
| `hoverable?` | `boolean` | `true` | 행에 마우스를 올렸을 때 hover 배경(grey_05)을 표시할지. 기본 `true` — `false` 면 표시하지 않는다 |
|
|
29
29
|
| `pagination?` | `STablePagination` | — | 페이지네이션 (있으면 하단 표시) |
|
|
30
30
|
| `internalPagination?` | `boolean` | `false` | 테이블 내부에서 페이지네이션을 직접 관리 (rows를 내부 슬라이싱) |
|
|
31
31
|
| `showRowsPerPageSelect?` | `boolean` | `false` | 페이지당 행 수 셀렉트 표시 |
|
|
@@ -244,7 +244,7 @@ export interface STableProps {
|
|
|
244
244
|
* 사용자가 고른 밀도를 다음 방문까지 기억해 두려는(로컬 저장 등) 페이지만 받으면 된다.
|
|
245
245
|
*/
|
|
246
246
|
onDenseChange?: (dense: boolean) => void;
|
|
247
|
-
/**
|
|
247
|
+
/** 행에 마우스를 올렸을 때 hover 배경(grey_05)을 표시할지. 기본 `true` — `false` 면 표시하지 않는다 */
|
|
248
248
|
hoverable?: boolean;
|
|
249
249
|
/** 페이지네이션 (있으면 하단 표시) */
|
|
250
250
|
pagination?: STablePagination;
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
| Prop | Type | Default | Description |
|
|
10
10
|
|------|------|---------|-------------|
|
|
11
11
|
| `value?` | `string` | — | 값 (제어) |
|
|
12
|
-
| `
|
|
12
|
+
| `size?` | `SFieldSize` | `'sm'` | 크기 — `SInput` 등 다른 필드와 같은 등급이다. 글자 크기·행간·안쪽 여백·모서리가 함께 바뀐다. 한 줄 필드와 나란히 놓이는 자리라면 같은 등급으로 맞춘다. |
|
|
13
|
+
| `rows?` | `number` | `MIN_ROWS` | 행 수 — 기본 2줄이고, 그보다 크게 주면 그만큼 높아진다. 줄여도 `size` 가 정한 최소 높이(2줄) 아래로는 내려가지 않는다. |
|
|
14
|
+
| `autogrow?` | `boolean` | `false` | 입력 내용에 맞춰 높이가 늘어난다 — 스크롤 대신 필드가 자란다. 켜면 모서리를 끌어 크기를 바꾸는 손잡이가 사라진다. 사용자가 늘려 둔 높이를 다음 타이핑이 도로 계산해 버려, 둘을 함께 두면 끌어도 제자리로 돌아가는 것처럼 보이기 때문이다. **상한을 정하려면 `maxRows` 를 함께 준다.** 상한이 없으면 긴 글에서 필드가 끝없이 자라 모달·드로어에서는 아래쪽 버튼을 화면 밖으로 밀어낸다. |
|
|
15
|
+
| `maxRows?` | `number` | — | `autogrow` 의 상한(줄 수). 여기 닿으면 더 자라지 않고 그 안에서 스크롤한다. `autogrow` 가 꺼져 있으면 아무 일도 하지 않는다. |
|
|
13
16
|
| `rules?` | `Rule[]` | — | 유효성 규칙 — blur 시 자동 검증 |
|
|
14
17
|
| `status?` | `SFieldStatus` | — | 필드 상태 ('default' | 'pass' | 'error') |
|
|
15
18
|
| `focused?` | `boolean` | — | 포커스 상태를 밖에서 지정한다 — 강조 테두리·링이 즉시 걸린다 (SField 로 그대로 넘어간다) |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TextareaHTMLAttributes, type CSSProperties } from 'react';
|
|
2
|
-
import { type SFieldAddonAlign, type SFieldStatus } from '../SField';
|
|
2
|
+
import { type SFieldAddonAlign, type SFieldSize, type SFieldStatus } from '../SField';
|
|
3
3
|
import { type SIconName } from '../SIcon';
|
|
4
4
|
import { type SColor } from '../../lib/color';
|
|
5
5
|
import { type Rule } from '../../lib/form';
|
|
@@ -12,8 +12,31 @@ export interface STextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextArea
|
|
|
12
12
|
onValueChange?: (value: string) => void;
|
|
13
13
|
/** 네이티브 onChange (form-agnostic) */
|
|
14
14
|
onChange?: TextareaHTMLAttributes<HTMLTextAreaElement>['onChange'];
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* 크기 — `SInput` 등 다른 필드와 같은 등급이다. 글자 크기·행간·안쪽 여백·모서리가 함께 바뀐다.
|
|
17
|
+
* 한 줄 필드와 나란히 놓이는 자리라면 같은 등급으로 맞춘다.
|
|
18
|
+
*/
|
|
19
|
+
size?: SFieldSize;
|
|
20
|
+
/**
|
|
21
|
+
* 행 수 — 기본 2줄이고, 그보다 크게 주면 그만큼 높아진다.
|
|
22
|
+
* 줄여도 `size` 가 정한 최소 높이(2줄) 아래로는 내려가지 않는다.
|
|
23
|
+
*/
|
|
16
24
|
rows?: number;
|
|
25
|
+
/**
|
|
26
|
+
* 입력 내용에 맞춰 높이가 늘어난다 — 스크롤 대신 필드가 자란다.
|
|
27
|
+
*
|
|
28
|
+
* 켜면 모서리를 끌어 크기를 바꾸는 손잡이가 사라진다. 사용자가 늘려 둔 높이를 다음 타이핑이
|
|
29
|
+
* 도로 계산해 버려, 둘을 함께 두면 끌어도 제자리로 돌아가는 것처럼 보이기 때문이다.
|
|
30
|
+
*
|
|
31
|
+
* **상한을 정하려면 `maxRows` 를 함께 준다.** 상한이 없으면 긴 글에서 필드가 끝없이 자라
|
|
32
|
+
* 모달·드로어에서는 아래쪽 버튼을 화면 밖으로 밀어낸다.
|
|
33
|
+
*/
|
|
34
|
+
autogrow?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* `autogrow` 의 상한(줄 수). 여기 닿으면 더 자라지 않고 그 안에서 스크롤한다.
|
|
37
|
+
* `autogrow` 가 꺼져 있으면 아무 일도 하지 않는다.
|
|
38
|
+
*/
|
|
39
|
+
maxRows?: number;
|
|
17
40
|
/** 유효성 규칙 — blur 시 자동 검증 */
|
|
18
41
|
rules?: Rule[];
|
|
19
42
|
/** 필드 상태 ('default' | 'pass' | 'error') */
|