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:
kmh
2026-03-12 13:27:01 +09:00
parent cc61ef3ff4
commit 62513ad2f0
6 changed files with 219 additions and 33 deletions

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>