feat: enhance responsive grid rendering and filter width management
- Introduced a new ProportionalRenderer component to improve the layout of components in the ResponsiveGridRenderer based on the canvas width. - Implemented dynamic resizing of components using ResizeObserver to ensure proper rendering across different screen sizes. - Updated filter width handling in FilterPanel and TableSettingsModal to restrict width values between 10% and 100%, enhancing usability and consistency. - Adjusted the TableSearchWidget to reflect the new percentage-based width for filters, improving the overall layout and responsiveness. These changes aim to enhance the user experience by providing a more flexible and responsive design for grid layouts and filter components.
This commit is contained in:
parent
cc61ef3ff4
commit
62513ad2f0
|
|
@ -202,6 +202,66 @@ function FullWidthOverlayRow({
|
|||
);
|
||||
}
|
||||
|
||||
function ProportionalRenderer({
|
||||
components,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
renderComponent,
|
||||
}: ResponsiveGridRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerW, setContainerW] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w && w > 0) setContainerW(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const ratio = containerW > 0 ? containerW / canvasWidth : 1;
|
||||
|
||||
const maxBottom = topLevel.reduce((max, c) => {
|
||||
const bottom = c.position.y + (c.size?.height || 40);
|
||||
return Math.max(max, bottom);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-screen-runtime="true"
|
||||
className="bg-background relative w-full overflow-x-hidden"
|
||||
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
|
||||
>
|
||||
{containerW > 0 &&
|
||||
topLevel.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
data-component-id={component.id}
|
||||
data-component-type={typeId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${(component.position.x / canvasWidth) * 100}%`,
|
||||
top: `${component.position.y * ratio}px`,
|
||||
width: `${((component.size?.width || 100) / canvasWidth) * 100}%`,
|
||||
height: `${(component.size?.height || 40) * ratio}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResponsiveGridRenderer({
|
||||
components,
|
||||
canvasWidth,
|
||||
|
|
@ -211,6 +271,18 @@ export function ResponsiveGridRenderer({
|
|||
const { isMobile } = useResponsive();
|
||||
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
|
||||
|
||||
if (!isMobile && !hasFullWidthComponent) {
|
||||
return (
|
||||
<ProportionalRenderer
|
||||
components={components}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = groupComponentsIntoRows(topLevel);
|
||||
const processedRows: ProcessedRow[] = [];
|
||||
|
|
@ -334,7 +406,7 @@ export function ResponsiveGridRenderer({
|
|||
style={{
|
||||
width: isFullWidth ? "100%" : undefined,
|
||||
flexBasis: useFlexHeight ? undefined : flexBasis,
|
||||
flexGrow: 1,
|
||||
flexGrow: percentWidth,
|
||||
flexShrink: 1,
|
||||
minWidth: isMobile ? "100%" : undefined,
|
||||
minHeight: useFlexHeight ? "300px" : undefined,
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
operator: "contains", // 기본 연산자
|
||||
value: "",
|
||||
filterType: cf.filterType,
|
||||
width: cf.width || 200, // 너비 포함 (기본 200px)
|
||||
width: cf.width && cf.width >= 10 && cf.width <= 100 ? cf.width : 25,
|
||||
}));
|
||||
|
||||
// localStorage에 저장 (화면별로 독립적)
|
||||
|
|
@ -334,20 +334,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
{/* 너비 입력 */}
|
||||
<Input
|
||||
type="number"
|
||||
value={filter.width || 200}
|
||||
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
|
||||
onChange={(e) => {
|
||||
const newWidth = parseInt(e.target.value) || 200;
|
||||
const newWidth = Math.min(100, Math.max(10, parseInt(e.target.value) || 25));
|
||||
setColumnFilters((prev) =>
|
||||
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
|
||||
);
|
||||
}}
|
||||
disabled={!filter.enabled}
|
||||
placeholder="너비"
|
||||
placeholder="25"
|
||||
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
|
||||
min={50}
|
||||
max={500}
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">px</span>
|
||||
<span className="text-muted-foreground text-xs">%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
inputType,
|
||||
enabled: false,
|
||||
filterType,
|
||||
width: 200,
|
||||
width: 25,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -271,7 +271,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
operator: "contains",
|
||||
value: "",
|
||||
filterType: f.filterType,
|
||||
width: f.width || 200,
|
||||
width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
|
||||
}));
|
||||
onFiltersApplied?.(activeFilters);
|
||||
|
||||
|
|
@ -498,15 +498,15 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={100}
|
||||
max={400}
|
||||
value={filter.width || 200}
|
||||
min={10}
|
||||
max={100}
|
||||
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
|
||||
onChange={(e) =>
|
||||
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200)
|
||||
handleFilterWidthChange(filter.columnName, Math.min(100, Math.max(10, parseInt(e.target.value) || 25)))
|
||||
}
|
||||
className="h-7 w-16 text-center text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">px</span>
|
||||
<span className="text-muted-foreground text-xs">%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -648,12 +648,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
const renderFilterInput = (filter: TableFilter) => {
|
||||
const column = currentTable?.columns.find((c) => c.columnName === filter.columnName);
|
||||
const value = filterValues[filter.columnName] || "";
|
||||
const width = filter.width || 200; // 기본 너비 200px
|
||||
|
||||
switch (filter.filterType) {
|
||||
case "date":
|
||||
return (
|
||||
<div className="w-full sm:w-auto" style={{ maxWidth: `${width}px` }}>
|
||||
<div className="w-full">
|
||||
<ModernDatePicker
|
||||
label={column?.columnLabel || filter.columnName}
|
||||
value={value ? (typeof value === "string" ? { from: new Date(value), to: new Date(value) } : value) : {}}
|
||||
|
|
@ -676,8 +675,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
||||
className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:w-auto sm:text-sm"
|
||||
style={{ maxWidth: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||
style={{ height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
placeholder={column?.columnLabel}
|
||||
/>
|
||||
);
|
||||
|
|
@ -726,10 +725,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"h-9 min-h-9 w-full justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:w-auto sm:text-sm",
|
||||
"h-9 min-h-9 w-full justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm",
|
||||
selectedValues.length === 0 && "text-muted-foreground",
|
||||
)}
|
||||
style={{ maxWidth: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
style={{ height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
>
|
||||
<span className="truncate">{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
|
|
@ -781,8 +780,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
||||
className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:w-auto sm:text-sm"
|
||||
style={{ maxWidth: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||
style={{ height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
placeholder={column?.columnLabel}
|
||||
/>
|
||||
);
|
||||
|
|
@ -802,9 +801,18 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
{/* 필터 입력 필드들 */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
{activeFilters.map((filter) => (
|
||||
<div key={filter.columnName}>{renderFilterInput(filter)}</div>
|
||||
))}
|
||||
{activeFilters.map((filter) => {
|
||||
const widthPercent = filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25;
|
||||
return (
|
||||
<div
|
||||
key={filter.columnName}
|
||||
className="w-full sm:w-auto"
|
||||
style={{ flex: `0 1 ${widthPercent}%`, minWidth: "120px" }}
|
||||
>
|
||||
{renderFilterInput(filter)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" size="sm" onClick={handleResetFilters} className="h-9 shrink-0 text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function TableSearchWidgetConfigPanel({
|
|||
columnName: "",
|
||||
columnLabel: "",
|
||||
filterType: "text",
|
||||
width: 200,
|
||||
width: 25,
|
||||
};
|
||||
const updatedFilters = [...localPresetFilters, newFilter];
|
||||
setLocalPresetFilters(updatedFilters);
|
||||
|
|
@ -346,15 +346,15 @@ export function TableSearchWidgetConfigPanel({
|
|||
|
||||
{/* 너비 */}
|
||||
<div>
|
||||
<Label className="text-[10px] sm:text-xs mb-1">너비 (px)</Label>
|
||||
<Label className="text-[10px] sm:text-xs mb-1">너비 (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={filter.width || 200}
|
||||
onChange={(e) => updateFilter(filter.id, "width", parseInt(e.target.value))}
|
||||
placeholder="200"
|
||||
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
|
||||
onChange={(e) => updateFilter(filter.id, "width", Math.min(100, Math.max(10, parseInt(e.target.value) || 25)))}
|
||||
placeholder="25"
|
||||
className="h-7 text-xs"
|
||||
min={100}
|
||||
max={500}
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* 회사 기본정보 화면 - 컴포넌트 렌더링 비율 정밀 분석
|
||||
*
|
||||
* 사용법: 브라우저에서 회사 기본정보 화면을 연 상태에서
|
||||
* F12 → Console 탭 → 이 스크립트 전체를 붙여넣고 Enter
|
||||
*/
|
||||
|
||||
(function analyzeLayout() {
|
||||
const results = { part1: null, part2: null };
|
||||
|
||||
// ========== Part 1: DesktopCanvasRenderer 구조 확인 ==========
|
||||
const runtime = document.querySelector('[data-screen-runtime="true"]');
|
||||
if (!runtime) {
|
||||
console.warn('⚠️ [data-screen-runtime="true"] 요소를 찾을 수 없습니다.');
|
||||
console.log('대안: ScreenModal 기반 렌더링이거나 다른 구조일 수 있습니다.');
|
||||
results.part1 = { error: 'Runtime not found' };
|
||||
} else {
|
||||
const rect = runtime.getBoundingClientRect();
|
||||
const inner = runtime.firstElementChild;
|
||||
|
||||
results.part1 = {
|
||||
runtimeContainer: { width: rect.width, height: rect.height },
|
||||
innerDiv: null,
|
||||
components: [],
|
||||
};
|
||||
|
||||
if (inner) {
|
||||
const style = inner.style;
|
||||
results.part1.innerDiv = {
|
||||
width: style.width,
|
||||
height: style.height,
|
||||
transform: style.transform,
|
||||
transformOrigin: style.transformOrigin,
|
||||
position: style.position,
|
||||
};
|
||||
|
||||
const comps = inner.querySelectorAll('[data-component-id]');
|
||||
comps.forEach((comp) => {
|
||||
const s = comp.style;
|
||||
const r = comp.getBoundingClientRect();
|
||||
results.part1.components.push({
|
||||
type: comp.getAttribute('data-component-type'),
|
||||
id: comp.getAttribute('data-component-id'),
|
||||
stylePos: `(${s.left}, ${s.top})`,
|
||||
styleSize: `${s.width} x ${s.height}`,
|
||||
renderedSize: `${Math.round(r.width)} x ${Math.round(r.height)}`,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// ResponsiveGridRenderer (flex 기반) 구조일 수 있음 - 행 단위로 확인
|
||||
const rows = runtime.querySelectorAll(':scope > div');
|
||||
results.part1.rows = [];
|
||||
rows.forEach((row, i) => {
|
||||
const children = row.children;
|
||||
const rowData = { rowIndex: i, childCount: children.length, children: [] };
|
||||
Array.from(children).forEach((child, j) => {
|
||||
const cs = window.getComputedStyle(child);
|
||||
const r = child.getBoundingClientRect();
|
||||
rowData.children.push({
|
||||
type: child.getAttribute('data-component-type') || 'unknown',
|
||||
width: Math.round(r.width),
|
||||
height: Math.round(r.height),
|
||||
flexGrow: cs.flexGrow,
|
||||
flexBasis: cs.flexBasis,
|
||||
});
|
||||
});
|
||||
results.part1.rows.push(rowData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Part 2: wrapper vs child 크기 확인 ==========
|
||||
const comps = document.querySelectorAll('[data-component-id]');
|
||||
results.part2 = [];
|
||||
comps.forEach((comp) => {
|
||||
const type = comp.getAttribute('data-component-type');
|
||||
const child = comp.firstElementChild;
|
||||
if (child) {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
const compRect = comp.getBoundingClientRect();
|
||||
results.part2.push({
|
||||
type,
|
||||
wrapper: `${Math.round(compRect.width)}x${Math.round(compRect.height)}`,
|
||||
child: `${Math.round(childRect.width)}x${Math.round(childRect.height)}`,
|
||||
overflow: childRect.width > compRect.width ? 'YES' : 'no',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 결과 출력 ==========
|
||||
console.log('========== Part 1: Runtime 구조 ==========');
|
||||
console.log(JSON.stringify(results.part1, null, 2));
|
||||
|
||||
console.log('\n========== Part 2: Wrapper vs Child ==========');
|
||||
results.part2.forEach((r) => {
|
||||
console.log(`${r.type}: wrapper=${r.wrapper}, child=${r.child}, overflow=${r.overflow}`);
|
||||
});
|
||||
|
||||
// scale 값 추출 (transform에서)
|
||||
if (results.part1?.innerDiv?.transform) {
|
||||
const m = results.part1.innerDiv.transform.match(/scale\(([^)]+)\)/);
|
||||
if (m) console.log('\n📐 Scale 값:', m[1]);
|
||||
}
|
||||
|
||||
return results;
|
||||
})();
|
||||
Loading…
Reference in New Issue