feat(pop-string-list): 오버플로우 시스템 개편 - 더보기 점진 확장 + 페이지네이션 모드

- "전체보기" 토글을 "더보기" 점진 확장으로 변경 (loadMoreCount씩 추가, maxExpandRows 상한)
- 페이지네이션 모드 추가: bottom(하단 페이지 표시) / side(좌우 화살표) 스타일 선택
- StepOverflow 설정 UI에 오버플로우 방식 Select + 모드별 분기 설정 추가
- PopRenderer viewer 모드에서 gridTemplateRows minmax(auto) 적용으로 동적 높이 확장 지원

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-23 15:08:52 +09:00
parent fc0e913b8a
commit f6461ae563
5 changed files with 299 additions and 76 deletions

View File

@ -122,18 +122,27 @@ export default function PopRenderer({
return Math.max(10, maxRowEnd + 3); return Math.max(10, maxRowEnd + 3);
}, [components, overrides, mode, hiddenIds]); }, [components, overrides, mode, hiddenIds]);
// CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준) // CSS Grid 스타일
// 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
// 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
const rowTemplate = isDesignMode
? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
: `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`;
const autoRowHeight = isDesignMode
? `${breakpoint.rowHeight}px`
: `minmax(${breakpoint.rowHeight}px, auto)`;
const gridStyle = useMemo((): React.CSSProperties => ({ const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`, gridTemplateRows: rowTemplate,
gridAutoRows: `${breakpoint.rowHeight}px`, gridAutoRows: autoRowHeight,
gap: `${finalGap}px`, gap: `${finalGap}px`,
padding: `${finalPadding}px`, padding: `${finalPadding}px`,
minHeight: "100%", minHeight: "100%",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
position: "relative", position: "relative",
}), [breakpoint, finalGap, finalPadding, dynamicRowCount]); }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
// 그리드 가이드 셀 생성 (동적 행 수) // 그리드 가이드 셀 생성 (동적 행 수)
const gridCells = useMemo(() => { const gridCells = useMemo(() => {
@ -265,11 +274,11 @@ export default function PopRenderer({
); );
} }
// 뷰어 모드: 드래그 없는 일반 렌더링 // 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용)
return ( return (
<div <div
key={comp.id} key={comp.id}
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10" className="relative rounded-lg border-2 border-gray-200 bg-white transition-all z-10"
style={positionStyle} style={positionStyle}
> >
<ComponentContent <ComponentContent
@ -580,7 +589,7 @@ function renderActualComponent(component: PopComponentDefinitionV5, screenId?: s
if (ActualComp) { if (ActualComp) {
return ( return (
<div className="h-full w-full overflow-hidden"> <div className="w-full min-h-full">
<ActualComp config={component.config} label={component.label} screenId={screenId} /> <ActualComp config={component.config} label={component.label} screenId={screenId} />
</div> </div>
); );

View File

@ -5,11 +5,11 @@
* *
* 모드: 엑셀형 / (CSS Grid) * 모드: 엑셀형 / (CSS Grid)
* 모드: (CSS Grid + colSpan/rowSpan) * 모드: (CSS Grid + colSpan/rowSpan)
* 오버플로우: visibleRows + "전체보기" * 오버플로우: visibleRows + "더보기"
*/ */
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { ChevronDown, ChevronUp, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react"; import { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Popover, Popover,
@ -70,7 +70,10 @@ export function PopStringListComponent({
const [rows, setRows] = useState<RowData[]>([]); const [rows, setRows] = useState<RowData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false); // 더보기 모드: 현재 표시 중인 행 수
const [displayCount, setDisplayCount] = useState<number>(0);
// 페이지네이션 모드: 현재 페이지 (1부터 시작)
const [currentPage, setCurrentPage] = useState(1);
// 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음) // 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-1); const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-1);
@ -112,21 +115,54 @@ export function PopStringListComponent({
[rows, publish, screenId] [rows, publish, screenId]
); );
// 오버플로우 계산 (JSON 복원 시 string 유입 방어) // 오버플로우 설정 (JSON 복원 시 string 유입 방어)
const overflowMode = overflow?.mode || "loadMore";
const visibleRows = Number(overflow?.visibleRows) || 5; const visibleRows = Number(overflow?.visibleRows) || 5;
const maxExpandRows = Number(overflow?.maxExpandRows) || 20; const loadMoreCount = Number(overflow?.loadMoreCount) || 5;
const maxExpandRows = Number(overflow?.maxExpandRows) || 50;
const showExpandButton = overflow?.showExpandButton ?? true; const showExpandButton = overflow?.showExpandButton ?? true;
const pageSize = Number(overflow?.pageSize) || visibleRows;
const paginationStyle = overflow?.paginationStyle || "bottom";
// 표시할 데이터 슬라이스 // --- 더보기 모드 ---
const visibleData = expanded useEffect(() => {
? rows.slice(0, maxExpandRows) setDisplayCount(visibleRows);
: rows.slice(0, visibleRows); }, [visibleRows]);
const hasMore = rows.length > visibleRows;
// 확장/축소 토글 const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, rows.length);
const toggleExpanded = useCallback(() => { const hasMore = showExpandButton && rows.length > effectiveLimit && effectiveLimit < maxExpandRows;
setExpanded((prev) => !prev); const isExpanded = effectiveLimit > visibleRows;
}, []);
const handleLoadMore = useCallback(() => {
setDisplayCount((prev) => {
const current = prev || visibleRows;
return Math.min(current + loadMoreCount, maxExpandRows, rows.length);
});
}, [visibleRows, loadMoreCount, maxExpandRows, rows.length]);
const handleCollapse = useCallback(() => {
setDisplayCount(visibleRows);
}, [visibleRows]);
// --- 페이지네이션 모드 ---
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
useEffect(() => {
setCurrentPage(1);
}, [pageSize, rows.length]);
const handlePageChange = useCallback((page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
}, [totalPages]);
// --- 모드별 visibleData 결정 ---
const visibleData = useMemo(() => {
if (overflowMode === "pagination") {
const start = (currentPage - 1) * pageSize;
return rows.slice(start, start + pageSize);
}
return rows.slice(0, effectiveLimit);
}, [overflowMode, rows, currentPage, pageSize, effectiveLimit]);
// dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용) // dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용)
const dsTableName = dataSource?.tableName; const dsTableName = dataSource?.tableName;
@ -236,8 +272,10 @@ export function PopStringListComponent({
); );
} }
const isPaginationSide = overflowMode === "pagination" && paginationStyle === "side" && totalPages > 1;
return ( return (
<div className={`flex h-full w-full flex-col overflow-hidden ${className || ""}`}> <div className={`flex w-full flex-col ${className || ""}`}>
{/* 헤더 */} {/* 헤더 */}
{header?.enabled && header.label && ( {header?.enabled && header.label && (
<div className="shrink-0 border-b px-3 py-2"> <div className="shrink-0 border-b px-3 py-2">
@ -246,12 +284,9 @@ export function PopStringListComponent({
)} )}
{/* 컨텐츠 */} {/* 컨텐츠 */}
<div className="flex-1 overflow-auto"> <div className={`flex-1 ${isPaginationSide ? "relative" : ""}`}>
{displayMode === "list" ? ( {displayMode === "list" ? (
<ListModeView <ListModeView columns={listColumns} data={visibleData} />
columns={listColumns}
data={visibleData}
/>
) : ( ) : (
<CardModeView <CardModeView
cardGrid={cardGrid} cardGrid={cardGrid}
@ -260,29 +295,96 @@ export function PopStringListComponent({
loadingRowId={loadingRowIdx} loadingRowId={loadingRowIdx}
/> />
)} )}
{isPaginationSide && (
<>
<Button
variant="ghost"
size="icon"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="absolute left-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="absolute right-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
</div> </div>
{/* 전체보기 버튼 */} {/* side 모드 페이지 인디케이터 (컨텐츠 아래 별도 영역) */}
{showExpandButton && hasMore && ( {isPaginationSide && (
<div className="shrink-0 border-t px-3 py-1.5"> <div className="shrink-0 flex justify-center py-1">
<span className="rounded-full bg-muted/50 px-2.5 py-0.5 text-[10px] tabular-nums text-muted-foreground">
{currentPage} / {totalPages}
</span>
</div>
)}
{/* 더보기 모드 컨트롤 */}
{overflowMode === "loadMore" && showExpandButton && (hasMore || isExpanded) && (
<div className="shrink-0 border-t px-3 py-1.5 flex gap-2">
{hasMore && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={toggleExpanded} onClick={handleLoadMore}
className="h-7 w-full text-xs text-muted-foreground" className="h-7 flex-1 text-xs text-muted-foreground"
>
<ChevronDown className="mr-1 h-3 w-3" />
({rows.length - effectiveLimit} )
</Button>
)}
{isExpanded && (
<Button
variant="ghost"
size="sm"
onClick={handleCollapse}
className="h-7 text-xs text-muted-foreground"
> >
{expanded ? (
<>
<ChevronUp className="mr-1 h-3 w-3" /> <ChevronUp className="mr-1 h-3 w-3" />
</>
) : (
<>
<ChevronDown className="mr-1 h-3 w-3" />
({rows.length})
</>
)}
</Button> </Button>
)}
</div>
)}
{/* 페이지네이션 bottom 모드 컨트롤 */}
{overflowMode === "pagination" && paginationStyle === "bottom" && totalPages > 1 && (
<div className="shrink-0 border-t px-3 py-1.5 flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{rows.length} {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, rows.length)}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="h-6 w-6"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-xs tabular-nums px-1">
{currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="h-6 w-6"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -67,7 +67,7 @@ interface ConfigPanelProps {
const DEFAULT_CONFIG: PopStringListConfig = { const DEFAULT_CONFIG: PopStringListConfig = {
displayMode: "list", displayMode: "list",
header: { enabled: true, label: "" }, header: { enabled: true, label: "" },
overflow: { visibleRows: 5, showExpandButton: true, maxExpandRows: 20 }, overflow: { visibleRows: 5, mode: "loadMore", showExpandButton: true, loadMoreCount: 5, maxExpandRows: 50, pageSize: 5, paginationStyle: "bottom" },
dataSource: { tableName: "" }, dataSource: { tableName: "" },
listColumns: [], listColumns: [],
cardGrid: undefined, cardGrid: undefined,
@ -414,6 +414,8 @@ function StepOverflow({
overflow: PopStringListConfig["overflow"]; overflow: PopStringListConfig["overflow"];
onChange: (overflow: PopStringListConfig["overflow"]) => void; onChange: (overflow: PopStringListConfig["overflow"]) => void;
}) { }) {
const mode = overflow.mode || "loadMore";
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@ -429,8 +431,33 @@ function StepOverflow({
className="mt-1 h-8 text-xs" className="mt-1 h-8 text-xs"
/> />
</div> </div>
<div>
<Label className="text-xs"> </Label>
<Select
value={mode}
onValueChange={(v) =>
onChange({ ...overflow, mode: v as "loadMore" | "pagination" })
}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="loadMore" className="text-xs">
( )
</SelectItem>
<SelectItem value="pagination" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{mode === "loadMore" && (
<>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Switch <Switch
checked={overflow.showExpandButton} checked={overflow.showExpandButton}
onCheckedChange={(showExpandButton) => onCheckedChange={(showExpandButton) =>
@ -439,6 +466,26 @@ function StepOverflow({
/> />
</div> </div>
{overflow.showExpandButton && ( {overflow.showExpandButton && (
<>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.loadMoreCount ?? 5}
onChange={(e) =>
onChange({
...overflow,
loadMoreCount: Number(e.target.value) || 5,
})
}
className="mt-1 h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-[10px]">
</p>
</div>
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <Input
@ -449,12 +496,65 @@ function StepOverflow({
onChange={(e) => onChange={(e) =>
onChange({ onChange({
...overflow, ...overflow,
maxExpandRows: Number(e.target.value) || 20, maxExpandRows: Number(e.target.value) || 50,
}) })
} }
className="mt-1 h-8 text-xs" className="mt-1 h-8 text-xs"
/> />
</div> </div>
</>
)}
</>
)}
{mode === "pagination" && (
<>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={100}
value={overflow.pageSize ?? overflow.visibleRows}
onChange={(e) =>
onChange({
...overflow,
pageSize: Number(e.target.value) || 5,
})
}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={overflow.paginationStyle || "bottom"}
onValueChange={(v) =>
onChange({
...overflow,
paginationStyle: v as "bottom" | "side",
})
}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bottom" className="text-xs">
</SelectItem>
<SelectItem value="side" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
{(overflow.paginationStyle || "bottom") === "bottom"
? "컴포넌트 하단에 이전/다음 버튼과 페이지 번호 표시"
: "컴포넌트 좌우에 화살표 버튼 표시"}
</p>
</div>
</>
)} )}
</div> </div>
); );

View File

@ -16,7 +16,7 @@ import type { PopStringListConfig } from "./types";
const defaultConfig: PopStringListConfig = { const defaultConfig: PopStringListConfig = {
displayMode: "list", displayMode: "list",
header: { enabled: true, label: "" }, header: { enabled: true, label: "" },
overflow: { visibleRows: 5, showExpandButton: true, maxExpandRows: 20 }, overflow: { visibleRows: 5, mode: "loadMore", showExpandButton: true, loadMoreCount: 5, maxExpandRows: 50, pageSize: 5, paginationStyle: "bottom" },
dataSource: { tableName: "" }, dataSource: { tableName: "" },
listColumns: [], listColumns: [],
cardGrid: undefined, cardGrid: undefined,

View File

@ -47,11 +47,23 @@ export interface ListColumnConfig {
alternateColumns?: string[]; // 런타임에서 전환 가능한 대체 컬럼 목록 alternateColumns?: string[]; // 런타임에서 전환 가능한 대체 컬럼 목록
} }
/** 오버플로우 방식 */
export type OverflowMode = "loadMore" | "pagination";
/** 페이지네이션 네비게이션 스타일 */
export type PaginationStyle = "bottom" | "side";
/** 오버플로우 설정 */ /** 오버플로우 설정 */
export interface OverflowConfig { export interface OverflowConfig {
visibleRows: number; // 기본 표시 행 수 visibleRows: number; // 기본 표시 행 수
showExpandButton: boolean; // "전체보기" 버튼 표시 mode: OverflowMode; // 오버플로우 방식
maxExpandRows: number; // 확장 시 최대 행 수 // 더보기 모드 전용
showExpandButton: boolean; // "더보기" 버튼 표시
loadMoreCount: number; // 더보기 클릭 시 추가 로딩 행 수
maxExpandRows: number; // 최대 표시 행 수 (무한 확장 방지)
// 페이지네이션 모드 전용
pageSize: number; // 페이지당 표시 행 수
paginationStyle: PaginationStyle; // bottom: 하단 페이지 표시, side: 좌우 화살표
} }
/** 헤더 설정 */ /** 헤더 설정 */