컴포넌트
Layout
컴포넌트 사이의 간격, 정렬과 줄 전환을 관리합니다.
설치
pnpm dlx ply-ui add layout card button input badge field select icon구성
| 컴포넌트 | 역할 | 기본값 |
|---|---|---|
| Stack | 위에서 아래로 내용 배치 | 16px 간격 |
| Inline | 버튼·배지를 가로로 배치하고 항목 단위로 줄 전환 | 8px 간격 |
| Grid | 부모 너비에 따라 열 수 변경 | 최소 열 너비 16rem, 16px 간격 |
| ControlGroup | 입력 영역이 남은 너비를 사용하고 액션 버튼의 너비 보존 | 8px 간격, 공간이 부족하면 줄 전환 |
gap은 0, 4, 8, 12, 16, 24, 32, 48 중 선택합니다. className, style, HTML 속성과 ref는 배치 요소에 전달합니다. 직접 CSS Grid나 Flex를 사용해도 됩니다.
입력과 버튼
import { ControlGroup } from "ply-ui/layout";
import { Input } from "ply-ui/input";
import { Button } from "ply-ui/button";
<ControlGroup>
<Input aria-label="검색어" placeholder="프로젝트 검색" />
<Button>검색</Button>
</ControlGroup>;입력 영역은 최소 12rem을 기준으로 늘어나며, 공간이 부족하면 버튼이 다음 줄로 이동합니다. 버튼 자체를 좁혀 글자를 여러 줄로 쪼개지 않습니다. 긴 버튼 이름 하나가 부모보다 크면 그 버튼 안에서 줄바꿈합니다.
실제 화면 구성
Grid minItemWidth="14rem"으로 필드 두 개를 배치합니다. 넓으면 두 열, 좁으면 한 열이 됩니다. 부모 패널의 너비가 기준이므로 별도의 화면 크기 조건은 필요하지 않습니다.
크기 규칙
입력·선택·표는 가용 너비를 사용합니다. Button·Badge는 내용 너비, IconButton은 지정한 정사각형 크기를 유지합니다. 부모를 채우는 버튼이 필요하면 className="w-full" 또는 style={{ width: "100%" }}을 지정합니다.
구현 코드
components/ui/layout.tsx
import type { ComponentProps, CSSProperties } from "react";
import { cx } from "../../lib/cx";
import "./layout.css";
type Spacing = 0 | 4 | 8 | 12 | 16 | 24 | 32 | 48;
type LayoutProps = ComponentProps<"div"> & {
/** 4px 간격 체계를 사용합니다. */
gap?: Spacing;
};
type Alignment = "start" | "center" | "end" | "stretch";
function spacing(gap: Spacing, style?: CSSProperties): CSSProperties {
return { "--rbx-layout-gap": `${gap}px`, ...style } as CSSProperties;
}
/** 세로 흐름과 간격을 관리합니다. 버튼·배지는 고유 너비, 입력은 가용 너비를 사용합니다. */
export function Stack({
gap = 16,
align = "stretch",
className,
style,
...props
}: LayoutProps & { align?: Alignment }) {
return (
<div
{...props}
data-align={align}
className={cx("rbx-stack", className)}
style={spacing(gap, style)}
/>
);
}
/** 액션·배지를 가로로 배치하고 공간이 부족하면 항목 단위로 다음 줄로 넘깁니다. */
export function Inline({
gap = 8,
align = "center",
justify = "start",
className,
style,
...props
}: LayoutProps & {
align?: Alignment;
justify?: "start" | "center" | "end" | "space-between";
}) {
return (
<div
{...props}
data-align={align}
data-justify={justify}
className={cx("rbx-inline", className)}
style={spacing(gap, style)}
/>
);
}
/** 부모 너비에 맞춰 열을 나눕니다. 좁은 패널에서는 한 열로 전환됩니다. */
export function Grid({
gap = 16,
minItemWidth = "16rem",
className,
style,
...props
}: LayoutProps & { minItemWidth?: CSSProperties["minWidth"] }) {
return (
<div
{...props}
className={cx("rbx-grid", className)}
style={
{
"--rbx-grid-min":
typeof minItemWidth === "number"
? `${minItemWidth}px`
: minItemWidth,
...spacing(gap, style),
} as CSSProperties
}
/>
);
}
/** 입력·선택 영역이 남은 공간을 사용하고, 액션 버튼은 읽을 수 있는 너비를 유지합니다. */
export function ControlGroup({
gap = 8,
className,
style,
...props
}: LayoutProps) {
return (
<div
{...props}
className={cx("rbx-control-group", className)}
style={spacing(gap, style)}
/>
);
}
components/ui/layout.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@layer components.ply {
:where(.rbx-stack, .rbx-inline, .rbx-grid, .rbx-control-group) {
min-inline-size: 0;
max-inline-size: 100%;
gap: var(--rbx-layout-gap, 16px);
}
/* 간격은 gap이 맡습니다. 제목·문단의 브라우저 기본 바깥 여백이 gap에 더해지지 않게 합니다. */
:where(.rbx-stack, .rbx-inline, .rbx-grid, .rbx-control-group) > :where(*) {
margin-block: 0;
}
.rbx-stack {
display: flex;
flex-direction: column;
}
:where(.rbx-stack, .rbx-inline)[data-align="start"] {
align-items: flex-start;
}
:where(.rbx-stack, .rbx-inline)[data-align="center"] {
align-items: center;
}
:where(.rbx-stack, .rbx-inline)[data-align="end"] {
align-items: flex-end;
}
:where(.rbx-stack, .rbx-inline)[data-align="stretch"] {
align-items: stretch;
}
.rbx-inline {
display: flex;
flex-wrap: wrap;
}
.rbx-inline[data-justify="center"] {
justify-content: center;
}
.rbx-inline[data-justify="end"] {
justify-content: flex-end;
}
.rbx-inline[data-justify="space-between"] {
justify-content: space-between;
}
.rbx-grid {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(100%, var(--rbx-grid-min, 16rem)), 1fr)
);
}
.rbx-control-group {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
}
.rbx-control-group
> :where(:not(button, [role="button"], .rbx-button, .rbx-icon-button)) {
flex: 1 1 12rem;
min-inline-size: 0;
}
}